diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index 40be42461c4930c..5340b4bf578cdf0 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -162,10 +162,12 @@ enabled: - x-pack/test/functional/apps/maps/group2/config.ts - x-pack/test/functional/apps/maps/group3/config.ts - x-pack/test/functional/apps/maps/group4/config.ts + - x-pack/test/functional/apps/ml/anomaly_detection/config.ts + - x-pack/test/functional/apps/ml/data_frame_analytics/config.ts - x-pack/test/functional/apps/ml/data_visualizer/config.ts - - x-pack/test/functional/apps/ml/group1/config.ts - - x-pack/test/functional/apps/ml/group2/config.ts - - x-pack/test/functional/apps/ml/group3/config.ts + - x-pack/test/functional/apps/ml/permissions/config.ts + - x-pack/test/functional/apps/ml/short_tests/config.ts + - x-pack/test/functional/apps/ml/stack_management_jobs/config.ts - x-pack/test/functional/apps/monitoring/config.ts - x-pack/test/functional/apps/remote_clusters/config.ts - x-pack/test/functional/apps/reporting_management/config.ts diff --git a/.buildkite/package-lock.json b/.buildkite/package-lock.json index 04e3c73fdd2f53d..6e200caf62ab1db 100644 --- a/.buildkite/package-lock.json +++ b/.buildkite/package-lock.json @@ -8,7 +8,7 @@ "name": "kibana-buildkite", "version": "1.0.0", "dependencies": { - "kibana-buildkite-library": "git+https://git@github.com/elastic/kibana-buildkite-library#ef419d4f761dd256cb59bfab9b59f8b91029eb40" + "kibana-buildkite-library": "git+https://git@github.com/elastic/kibana-buildkite-library#ae4994aba5f2e72edcc5914e2aa208086e4b7ea3" } }, "node_modules/@nodelib/fs.scandir": { @@ -184,11 +184,24 @@ "follow-redirects": "^1.14.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, "node_modules/before-after-hook": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.2.tgz", "integrity": "sha512-3pZEU3NT5BFUo/AD5ERPWOgQOCZITni6iavr5AUw5AUwQjMlI0kzu5btnyD39AF0gUEsDPwJT+oY1ORBJijPjQ==" }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/braces": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", @@ -355,14 +368,15 @@ }, "node_modules/kibana-buildkite-library": { "version": "1.0.0", - "resolved": "git+https://git@github.com/elastic/kibana-buildkite-library.git#ef419d4f761dd256cb59bfab9b59f8b91029eb40", - "integrity": "sha512-ou/Db/DAhyMeD0uQeLpJ/VfUCg0PGPssIYsr4gJZSTZoqxCDrMtE4nhtH8sXErHq0TaugdqergUhyhVHNBSJiA==", + "resolved": "git+https://git@github.com/elastic/kibana-buildkite-library.git#ae4994aba5f2e72edcc5914e2aa208086e4b7ea3", + "integrity": "sha512-zvMwrJZ7kytbV/rFLMrcKlHGLxrR5G9+mzNqBwvCb0+RIfZ3Kp2IbPkPxqimps/2ipjWTqg92UMv0cGsZedbYQ==", "license": "MIT", "dependencies": { "@octokit/rest": "^18.10.0", "axios": "^0.21.4", "globby": "^11.1.0", - "js-yaml": "^4.1.0" + "js-yaml": "^4.1.0", + "minimatch": "^5.0.1" } }, "node_modules/merge2": { @@ -385,6 +399,17 @@ "node": ">=8.6" } }, + "node_modules/minimatch": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", + "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -684,11 +709,24 @@ "follow-redirects": "^1.14.0" } }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, "before-after-hook": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.2.tgz", "integrity": "sha512-3pZEU3NT5BFUo/AD5ERPWOgQOCZITni6iavr5AUw5AUwQjMlI0kzu5btnyD39AF0gUEsDPwJT+oY1ORBJijPjQ==" }, + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "requires": { + "balanced-match": "^1.0.0" + } + }, "braces": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", @@ -801,14 +839,15 @@ } }, "kibana-buildkite-library": { - "version": "git+https://git@github.com/elastic/kibana-buildkite-library.git#ef419d4f761dd256cb59bfab9b59f8b91029eb40", - "integrity": "sha512-ou/Db/DAhyMeD0uQeLpJ/VfUCg0PGPssIYsr4gJZSTZoqxCDrMtE4nhtH8sXErHq0TaugdqergUhyhVHNBSJiA==", - "from": "kibana-buildkite-library@git+https://git@github.com/elastic/kibana-buildkite-library#ef419d4f761dd256cb59bfab9b59f8b91029eb40", + "version": "git+https://git@github.com/elastic/kibana-buildkite-library.git#ae4994aba5f2e72edcc5914e2aa208086e4b7ea3", + "integrity": "sha512-zvMwrJZ7kytbV/rFLMrcKlHGLxrR5G9+mzNqBwvCb0+RIfZ3Kp2IbPkPxqimps/2ipjWTqg92UMv0cGsZedbYQ==", + "from": "kibana-buildkite-library@git+https://git@github.com/elastic/kibana-buildkite-library#ae4994aba5f2e72edcc5914e2aa208086e4b7ea3", "requires": { "@octokit/rest": "^18.10.0", "axios": "^0.21.4", "globby": "^11.1.0", - "js-yaml": "^4.1.0" + "js-yaml": "^4.1.0", + "minimatch": "^5.0.1" } }, "merge2": { @@ -825,6 +864,14 @@ "picomatch": "^2.3.1" } }, + "minimatch": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", + "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", + "requires": { + "brace-expansion": "^2.0.1" + } + }, "node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", diff --git a/.buildkite/package.json b/.buildkite/package.json index daff8bd5db78143..163da13eb0aa569 100644 --- a/.buildkite/package.json +++ b/.buildkite/package.json @@ -3,6 +3,6 @@ "version": "1.0.0", "private": true, "dependencies": { - "kibana-buildkite-library": "git+https://git@github.com/elastic/kibana-buildkite-library#ef419d4f761dd256cb59bfab9b59f8b91029eb40" + "kibana-buildkite-library": "git+https://git@github.com/elastic/kibana-buildkite-library#ae4994aba5f2e72edcc5914e2aa208086e4b7ea3" } } diff --git a/.buildkite/pipelines/artifacts.yml b/.buildkite/pipelines/artifacts.yml index eac479d4d450f89..5f3b0dac3af0653 100644 --- a/.buildkite/pipelines/artifacts.yml +++ b/.buildkite/pipelines/artifacts.yml @@ -41,7 +41,17 @@ steps: - exit_status: '*' limit: 1 - - command: .buildkite/scripts/steps/artifacts/docker_context.sh + - command: KIBANA_DOCKER_CONTEXT=default .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: KIBANA_DOCKER_CONTEXT=cloud .buildkite/scripts/steps/artifacts/docker_context.sh label: 'Docker Context Verification' agents: queue: n2-2 diff --git a/.buildkite/pipelines/on_merge.yml b/.buildkite/pipelines/on_merge.yml index 586199f08292576..512f0a2c279a32e 100644 --- a/.buildkite/pipelines/on_merge.yml +++ b/.buildkite/pipelines/on_merge.yml @@ -107,6 +107,16 @@ steps: - exit_status: '-1' limit: 3 + - command: .buildkite/scripts/steps/bazel_cache/bootstrap_linux.sh + label: 'Populate local dev bazel cache (Linux)' + agents: + queue: n2-4-spot + timeout_in_minutes: 15 + retry: + automatic: + - exit_status: '-1' + limit: 3 + - wait: ~ continue_on_failure: true diff --git a/.buildkite/scripts/build_kibana.sh b/.buildkite/scripts/build_kibana.sh index 482f730284a94a1..d450e988799bc29 100755 --- a/.buildkite/scripts/build_kibana.sh +++ b/.buildkite/scripts/build_kibana.sh @@ -5,9 +5,9 @@ set -euo pipefail export KBN_NP_PLUGINS_BUILT=true echo "--- Build Kibana Distribution" -if [[ "${GITHUB_PR_LABELS:-}" == *"ci:build-all-platforms"* ]]; then +if is_pr_with_label "ci:build-all-platforms"; then node scripts/build --all-platforms --skip-os-packages -elif [[ "${GITHUB_PR_LABELS:-}" == *"ci:build-os-packages"* ]]; then +elif is_pr_with_label "ci:build-os-packages"; then node scripts/build --all-platforms --docker-cross-compile else node scripts/build diff --git a/.buildkite/scripts/common/env.sh b/.buildkite/scripts/common/env.sh index 82c42af67f226b3..b8b9ef2ffb7de06 100755 --- a/.buildkite/scripts/common/env.sh +++ b/.buildkite/scripts/common/env.sh @@ -41,7 +41,7 @@ export ELASTIC_APM_SERVER_URL=https://kibana-ci-apm.apm.us-central1.gcp.cloud.es export ELASTIC_APM_SECRET_TOKEN=7YKhoXsO4MzjhXjx2c if is_pr; then - if [[ "${GITHUB_PR_LABELS:-}" == *"ci:collect-apm"* ]]; then + if is_pr_with_label "ci:collect-apm"; then export ELASTIC_APM_ACTIVE=true export ELASTIC_APM_CONTEXT_PROPAGATION_ONLY=false else diff --git a/.buildkite/scripts/common/setup_bazel.sh b/.buildkite/scripts/common/setup_bazel.sh index e3791dfa393c737..40159ba9eaf6953 100755 --- a/.buildkite/scripts/common/setup_bazel.sh +++ b/.buildkite/scripts/common/setup_bazel.sh @@ -32,6 +32,15 @@ cat <> $KIBANA_DIR/.bazelrc EOF fi +if [[ "$BAZEL_CACHE_MODE" == "populate-local-gcs" ]]; then + echo "[bazel] enabling caching with GCS buckets for local dev" + +cat <> $KIBANA_DIR/.bazelrc + build --remote_cache=https://storage.googleapis.com/kibana-local-bazel-remote-cache + build --google_credentials=$BAZEL_LOCAL_DEV_CACHE_CREDENTIALS_FILE +EOF +fi + if [[ "$BAZEL_CACHE_MODE" == "buildbuddy" ]]; then echo "[bazel] enabling caching with Buildbuddy" cat <> $KIBANA_DIR/.bazelrc @@ -43,7 +52,7 @@ cat <> $KIBANA_DIR/.bazelrc EOF fi -if [[ "$BAZEL_CACHE_MODE" != @(gcs|buildbuddy|none|) ]]; then - echo "invalid value for BAZEL_CACHE_MODE received ($BAZEL_CACHE_MODE), expected one of [gcs,buildbuddy,none]" +if [[ "$BAZEL_CACHE_MODE" != @(gcs|populate-local-gcs|buildbuddy|none|) ]]; then + echo "invalid value for BAZEL_CACHE_MODE received ($BAZEL_CACHE_MODE), expected one of [gcs,populate-local-gcs|buildbuddy,none]" exit 1 fi diff --git a/.buildkite/scripts/common/util.sh b/.buildkite/scripts/common/util.sh index 18fa1b1d79000ea..293eb8e6b8d274d 100755 --- a/.buildkite/scripts/common/util.sh +++ b/.buildkite/scripts/common/util.sh @@ -14,6 +14,25 @@ is_pr() { false } +is_pr_with_label() { + match="$1" + + IFS=',' read -ra labels <<< "${GITHUB_PR_LABELS:-}" + + for label in "${labels[@]}" + do + if [ "$label" == "$match" ]; then + return + fi + done + + false +} + +is_auto_commit_disabled() { + is_pr_with_label "ci:no-auto-commit" +} + check_for_changed_files() { RED='\033[0;31m' YELLOW='\033[0;33m' @@ -23,7 +42,7 @@ check_for_changed_files() { GIT_CHANGES="$(git ls-files --modified -- . ':!:.bazelrc')" if [ "$GIT_CHANGES" ]; then - if [[ "$SHOULD_AUTO_COMMIT_CHANGES" == "true" && "${BUILDKITE_PULL_REQUEST:-}" ]]; then + if ! is_auto_commit_disabled && [[ "$SHOULD_AUTO_COMMIT_CHANGES" == "true" && "${BUILDKITE_PULL_REQUEST:-}" ]]; then NEW_COMMIT_MESSAGE="[CI] Auto-commit changed files from '$1'" PREVIOUS_COMMIT_MESSAGE="$(git log -1 --pretty=%B)" diff --git a/.buildkite/scripts/lifecycle/pre_command.sh b/.buildkite/scripts/lifecycle/pre_command.sh index 8f3776db3ca6b8f..11806ebf10e7379 100755 --- a/.buildkite/scripts/lifecycle/pre_command.sh +++ b/.buildkite/scripts/lifecycle/pre_command.sh @@ -132,6 +132,10 @@ export SYNTHETICS_REMOTE_KIBANA_URL KIBANA_BUILDBUDDY_CI_API_KEY=$(retry 5 5 vault read -field=value secret/kibana-issues/dev/kibana-buildbuddy-ci-api-key) export KIBANA_BUILDBUDDY_CI_API_KEY +BAZEL_LOCAL_DEV_CACHE_CREDENTIALS_FILE="$HOME/.kibana-ci-bazel-remote-cache-local-dev.json" +export BAZEL_LOCAL_DEV_CACHE_CREDENTIALS_FILE +retry 5 5 vault read -field=service_account_json secret/kibana-issues/dev/kibana-ci-bazel-remote-cache-local-dev > "$BAZEL_LOCAL_DEV_CACHE_CREDENTIALS_FILE" + # By default, all steps should set up these things to get a full environment before running # It can be skipped for pipeline upload steps though, to make job start time a little faster if [[ "${SKIP_CI_SETUP:-}" != "true" ]]; then diff --git a/.buildkite/scripts/pipelines/pull_request/pipeline.js b/.buildkite/scripts/pipelines/pull_request/pipeline.js index 35ea19c78cd93d7..6a4610284e40096 100644 --- a/.buildkite/scripts/pipelines/pull_request/pipeline.js +++ b/.buildkite/scripts/pipelines/pull_request/pipeline.js @@ -9,19 +9,7 @@ const execSync = require('child_process').execSync; const fs = require('fs'); const { areChangesSkippable, doAnyChangesMatch } = require('kibana-buildkite-library'); - -const SKIPPABLE_PATHS = [ - /^docs\//, - /^rfcs\//, - /^.ci\/.+\.yml$/, - /^.ci\/es-snapshots\//, - /^.ci\/pipeline-library\//, - /^.ci\/Jenkinsfile_[^\/]+$/, - /^\.github\//, - /\.md$/, - /^\.backportrc\.json$/, - /^nav-kibana-dev\.docnav\.json$/, -]; +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 @@ -47,7 +35,7 @@ const uploadPipeline = (pipelineContent) => { (async () => { try { - const skippable = await areChangesSkippable(SKIPPABLE_PATHS, REQUIRED_PATHS); + const skippable = await areChangesSkippable(SKIPPABLE_PR_MATCHERS, REQUIRED_PATHS); if (skippable) { console.log('All changes in PR are skippable. Skipping CI.'); diff --git a/.buildkite/scripts/pipelines/pull_request/skippable_pr_matchers.js b/.buildkite/scripts/pipelines/pull_request/skippable_pr_matchers.js new file mode 100644 index 000000000000000..2a36e66e11cd624 --- /dev/null +++ b/.buildkite/scripts/pipelines/pull_request/skippable_pr_matchers.js @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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/build.sh b/.buildkite/scripts/steps/artifacts/build.sh index 4519e5167c91fe8..337d0289daa7222 100644 --- a/.buildkite/scripts/steps/artifacts/build.sh +++ b/.buildkite/scripts/steps/artifacts/build.sh @@ -24,3 +24,10 @@ buildkite-agent artifact upload "dependencies-$FULL_VERSION.csv" buildkite-agent artifact upload "dependencies-$FULL_VERSION.csv.sha512.txt" buildkite-agent artifact upload 'i18n/*.json' cd - + +if [ -d .beats ]; then + cd .beats + buildkite-agent artifact upload 'metricbeat-*' + buildkite-agent artifact upload 'filebeat-*' + cd - +fi diff --git a/.buildkite/scripts/steps/artifacts/docker_context.sh b/.buildkite/scripts/steps/artifacts/docker_context.sh old mode 100644 new mode 100755 index a20544de18fd972..d01cbccfc76c146 --- a/.buildkite/scripts/steps/artifacts/docker_context.sh +++ b/.buildkite/scripts/steps/artifacts/docker_context.sh @@ -6,17 +6,29 @@ set -euo pipefail source .buildkite/scripts/steps/artifacts/env.sh +KIBANA_DOCKER_CONTEXT="${KIBANA_DOCKER_CONTEXT:="default"}" + echo "--- Create contexts" mkdir -p target node scripts/build --skip-initialize --skip-generic-folders --skip-platform-folders --skip-archives --docker-context-use-local-artifact $(echo "$BUILD_ARGS") -echo "--- Setup default context" +echo "--- Setup context" DOCKER_BUILD_FOLDER=$(mktemp -d) -tar -xf target/kibana-[0-9]*-docker-build-context.tar.gz -C "$DOCKER_BUILD_FOLDER" +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" +fi + +tar -xf "target/$DOCKER_CONTEXT_FILE" -C "$DOCKER_BUILD_FOLDER" cd $DOCKER_BUILD_FOLDER buildkite-agent artifact download "kibana-$FULL_VERSION-linux-x86_64.tar.gz" . --build "${KIBANA_BUILD_ID:-$BUILDKITE_BUILD_ID}" +if [[ "$KIBANA_DOCKER_CONTEXT" == "cloud" ]]; then + buildkite-agent artifact download "metricbeat-$FULL_VERSION-linux-x86_64.tar.gz" . --build "${KIBANA_BUILD_ID:-$BUILDKITE_BUILD_ID}" + buildkite-agent artifact download "filebeat-$FULL_VERSION-linux-x86_64.tar.gz" . --build "${KIBANA_BUILD_ID:-$BUILDKITE_BUILD_ID}" +fi echo "--- Build context" docker build . diff --git a/.buildkite/scripts/steps/bazel_cache/bootstrap_linux.sh b/.buildkite/scripts/steps/bazel_cache/bootstrap_linux.sh new file mode 100755 index 000000000000000..7798370bfbd3515 --- /dev/null +++ b/.buildkite/scripts/steps/bazel_cache/bootstrap_linux.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -euo pipefail + +source .buildkite/scripts/common/util.sh + +export BAZEL_CACHE_MODE=populate-local-gcs +export DISABLE_BOOTSTRAP_VALIDATION=true + +# Clear out bazel cache between runs to make sure that any artifacts that don't exist in the cache are uploaded +rm -rf ~/.bazel-cache + +.buildkite/scripts/bootstrap.sh diff --git a/.buildkite/scripts/steps/bazel_cache/bootstrap_mac.sh b/.buildkite/scripts/steps/bazel_cache/bootstrap_mac.sh index 62aabf496fd8a5a..ab642ec431486fa 100755 --- a/.buildkite/scripts/steps/bazel_cache/bootstrap_mac.sh +++ b/.buildkite/scripts/steps/bazel_cache/bootstrap_mac.sh @@ -2,13 +2,19 @@ set -euo pipefail -export BAZEL_CACHE_MODE=buildbuddy +source .buildkite/scripts/common/util.sh + +export BAZEL_CACHE_MODE=populate-local-gcs export DISABLE_BOOTSTRAP_VALIDATION=true +# Clear out bazel cache between runs to make sure that any artifacts that don't exist in the cache are uploaded +rm -rf ~/.bazel-cache + # Since our Mac agents are currently static, # use a temporary HOME directory that gets cleaned out between builds TMP_HOME="$WORKSPACE/tmp_home" rm -rf "$TMP_HOME" +mkdir -p "$TMP_HOME" export HOME="$TMP_HOME" .buildkite/scripts/bootstrap.sh diff --git a/.buildkite/scripts/steps/checks.sh b/.buildkite/scripts/steps/checks.sh index 024037a8a4bb96c..8388dc82f52545c 100755 --- a/.buildkite/scripts/steps/checks.sh +++ b/.buildkite/scripts/steps/checks.sh @@ -5,7 +5,7 @@ set -euo pipefail export DISABLE_BOOTSTRAP_VALIDATION=false .buildkite/scripts/bootstrap.sh -.buildkite/scripts/steps/checks/commit/commit.sh +.buildkite/scripts/steps/checks/precommit_hook.sh .buildkite/scripts/steps/checks/bazel_packages.sh .buildkite/scripts/steps/checks/telemetry.sh .buildkite/scripts/steps/checks/ts_projects.sh diff --git a/.buildkite/scripts/steps/checks/commit/commit.sh b/.buildkite/scripts/steps/checks/commit/commit.sh deleted file mode 100755 index 5ff2632103a6350..000000000000000 --- a/.buildkite/scripts/steps/checks/commit/commit.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -source .buildkite/scripts/common/util.sh - -# Runs pre-commit hook script for the files touched in the last commit. -# That way we can ensure a set of quick commit checks earlier as we removed -# the pre-commit hook installation by default. -# If files are more than 200 we will skip it and just use -# the further ci steps that already check linting and file casing for the entire repo. -echo --- Quick commit checks -checks-reporter-with-killswitch "Quick commit checks" \ - "$(dirname "${0}")/commit_check_runner.sh" diff --git a/.buildkite/scripts/steps/checks/commit/commit_check_runner.sh b/.buildkite/scripts/steps/checks/commit/commit_check_runner.sh deleted file mode 100755 index 8d35c3698f3e111..000000000000000 --- a/.buildkite/scripts/steps/checks/commit/commit_check_runner.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash - -run_quick_commit_checks() { - echo "!!!!!!!! ATTENTION !!!!!!!! -That check is intended to provide earlier CI feedback after we remove the automatic install for the local pre-commit hook. -If you want, you can still manually install the pre-commit hook locally by running 'node scripts/register_git_hook locally' -!!!!!!!!!!!!!!!!!!!!!!!!!!! -" - - node scripts/precommit_hook.js --ref HEAD~1..HEAD --max-files 200 --verbose -} - -run_quick_commit_checks diff --git a/.buildkite/scripts/steps/checks/precommit_hook.sh b/.buildkite/scripts/steps/checks/precommit_hook.sh new file mode 100755 index 000000000000000..8fa51a4f4d23ce1 --- /dev/null +++ b/.buildkite/scripts/steps/checks/precommit_hook.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source .buildkite/scripts/common/util.sh + +# Runs pre-commit hook script for the files touched in the last commit. +# That way we can ensure a set of quick commit checks earlier as we removed +# the pre-commit hook installation by default. +# If files are more than 200 we will skip it and just use +# the further ci steps that already check linting and file casing for the entire repo. +echo --- Run Precommit Hook + +echo "!!!!!!!! ATTENTION !!!!!!!! +That check is intended to provide earlier CI feedback after we remove the automatic install for the local pre-commit hook. +If you want, you can still manually install the pre-commit hook locally by running 'node scripts/register_git_hook locally' +!!!!!!!!!!!!!!!!!!!!!!!!!!!" + +node scripts/precommit_hook.js \ + --ref HEAD~1..HEAD \ + --max-files 200 \ + --verbose \ + --fix \ + --no-stage # we have to disable staging or check_for_changed_files won't see the changes + +check_for_changed_files 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' true diff --git a/.buildkite/scripts/steps/functional/scalability_dataset_extraction.sh b/.buildkite/scripts/steps/functional/scalability_dataset_extraction.sh index b2ce23db38fdb72..1a8bd77bd2893da 100755 --- a/.buildkite/scripts/steps/functional/scalability_dataset_extraction.sh +++ b/.buildkite/scripts/steps/functional/scalability_dataset_extraction.sh @@ -19,14 +19,20 @@ for i in "${journeys[@]}"; do JOURNEY_NAME="${i}" echo "Looking for JOURNEY=${JOURNEY_NAME} and BUILD_ID=${BUILD_ID} in APM traces" - ./node_modules/.bin/performance-testing-dataset-extractor -u "${USER_FROM_VAULT}" -p "${PASS_FROM_VAULT}" -c "${ES_SERVER_URL}" -b "${BUILD_ID}" -n "${JOURNEY_NAME}" + node scripts/extract_performance_testing_dataset \ + --journeyName "${JOURNEY_NAME}" \ + --buildId "${BUILD_ID}" \ + --es-url "${ES_SERVER_URL}" \ + --es-username "${USER_FROM_VAULT}" \ + --es-password "${PASS_FROM_VAULT}" done -# archive json files with traces and upload as build artifacts echo "--- Upload Kibana build, plugins and scalability traces to the public bucket" mkdir "${BUILD_ID}" -tar -czf "${BUILD_ID}/scalability_traces.tar.gz" output +# Archive json files with traces and upload as build artifacts +tar -czf "${BUILD_ID}/scalability_traces.tar.gz" -C target scalability_traces buildkite-agent artifact upload "${BUILD_ID}/scalability_traces.tar.gz" +# Upload Kibana build, plugins, commit sha and traces to the bucket buildkite-agent artifact download kibana-default.tar.gz ./"${BUILD_ID}" buildkite-agent artifact download kibana-default-plugins.tar.gz ./"${BUILD_ID}" echo "${BUILDKITE_COMMIT}" > "${BUILD_ID}/KIBANA_COMMIT_HASH" diff --git a/.buildkite/scripts/steps/lint.sh b/.buildkite/scripts/steps/lint.sh index e94e7be4c7db202..ad61fced12f5083 100755 --- a/.buildkite/scripts/steps/lint.sh +++ b/.buildkite/scripts/steps/lint.sh @@ -11,20 +11,26 @@ checks-reporter-with-killswitch "Lint: stylelint" \ node scripts/stylelint echo "stylelint ✅" +echo '--- Lint: eslint' # disable "Exit immediately" mode so that we can run eslint, capture it's exit code, and respond appropriately # after possibly commiting fixed files to the repo set +e; - -echo '--- Lint: eslint' -checks-reporter-with-killswitch "Lint: eslint" \ +if is_pr && ! is_auto_commit_disabled; then node scripts/eslint --no-cache --fix +else + node scripts/eslint --no-cache +fi eslint_exit=$? - # re-enable "Exit immediately" mode set -e; -check_for_changed_files 'node scripts/eslint --no-cache --fix' true +desc="node scripts/eslint --no-cache" +if is_pr && ! is_auto_commit_disabled; then + desc="$desc --fix" +fi + +check_for_changed_files "$desc" true if [[ "${eslint_exit}" != "0" ]]; then exit 1 diff --git a/.buildkite/scripts/steps/package_testing/test.sh b/.buildkite/scripts/steps/package_testing/test.sh index e5ed00f760864a2..86e7bf8138875a1 100755 --- a/.buildkite/scripts/steps/package_testing/test.sh +++ b/.buildkite/scripts/steps/package_testing/test.sh @@ -43,7 +43,9 @@ vagrant provision "$TEST_PACKAGE" export TEST_BROWSER_HEADLESS=1 export TEST_KIBANA_URL="http://elastic:changeme@$KIBANA_IP_ADDRESS:5601" -export TEST_ES_URL=http://elastic:changeme@192.168.56.1:9200 +export TEST_ES_URL="http://elastic:changeme@192.168.56.1:9200" cd x-pack -node scripts/functional_test_runner.js --include-tag=smoke + +echo "--- FTR - Reporting" +node scripts/functional_test_runner.js --config test/functional/apps/visualize/config.ts --include-tag=smoke --quiet diff --git a/.buildkite/scripts/steps/storybooks/build_and_upload.js b/.buildkite/scripts/steps/storybooks/build_and_upload.js index becb8f1bd871f1a..c541f5954875368 100644 --- a/.buildkite/scripts/steps/storybooks/build_and_upload.js +++ b/.buildkite/scripts/steps/storybooks/build_and_upload.js @@ -38,6 +38,7 @@ const STORYBOOKS = [ 'security_solution', 'shared_ux', 'ui_actions_enhanced', + 'unified_search', ]; const GITHUB_CONTEXT = 'Build and Publish Storybooks'; diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 156a306b12e8906..abd63289e04801e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -83,6 +83,7 @@ /x-pack/plugins/ui_actions_enhanced/ @elastic/kibana-app-services /x-pack/plugins/runtime_fields @elastic/kibana-app-services /x-pack/test/search_sessions_integration/ @elastic/kibana-app-services +/src/plugins/dashboard/public/application/embeddable/viewport/print_media @elastic/kibana-app-services ### Observability Plugins @@ -186,10 +187,11 @@ /x-pack/test/screenshot_creation/apps/ml_docs @elastic/ml-ui /x-pack/test/screenshot_creation/services/ml_screenshots.ts @elastic/ml-ui -# ML team owns and maintains the transform plugin despite it living in the Data management section. -/x-pack/plugins/transform/ @elastic/ml-ui +# Additional plugins maintained by the ML team. +/x-pack/plugins/aiops/ @elastic/ml-ui /x-pack/plugins/data_visualizer/ @elastic/ml-ui /x-pack/plugins/file_upload/ @elastic/ml-ui +/x-pack/plugins/transform/ @elastic/ml-ui /x-pack/test/accessibility/apps/transform.ts @elastic/ml-ui /x-pack/test/api_integration/apis/transform/ @elastic/ml-ui /x-pack/test/api_integration_basic/apis/transform/ @elastic/ml-ui @@ -498,6 +500,7 @@ /x-pack/plugins/security_solution/public/common/components/health_truncate_text @elastic/security-detections-response-rules /x-pack/plugins/security_solution/public/common/components/links_to_docs @elastic/security-detections-response-rules /x-pack/plugins/security_solution/public/common/components/callouts @elastic/security-detections-response-rules +/x-pack/plugins/security_solution/public/common/components/ml_popover @elastic/security-detections-response-rules /x-pack/plugins/security_solution/public/detections/components/callouts @elastic/security-detections-response-rules /x-pack/plugins/security_solution/public/detections/mitre @elastic/security-detections-response-rules /x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules @elastic/security-detections-response-rules diff --git a/docs/api/actions-and-connectors.asciidoc b/docs/api/actions-and-connectors.asciidoc index ff4cb8401091e7b..f8d286e00b856fb 100644 --- a/docs/api/actions-and-connectors.asciidoc +++ b/docs/api/actions-and-connectors.asciidoc @@ -23,13 +23,13 @@ For deprecated APIs, refer to <>. For information about the actions and connectors that {kib} supports, refer to <>. -include::actions-and-connectors/get.asciidoc[] -include::actions-and-connectors/get_all.asciidoc[] +include::actions-and-connectors/create.asciidoc[leveloffset=+1] +include::actions-and-connectors/delete.asciidoc[leveloffset=+1] +include::actions-and-connectors/get.asciidoc[leveloffset=+1] +include::actions-and-connectors/get_all.asciidoc[leveloffset=+1] include::actions-and-connectors/list.asciidoc[] -include::actions-and-connectors/create.asciidoc[] include::actions-and-connectors/update.asciidoc[] include::actions-and-connectors/execute.asciidoc[] -include::actions-and-connectors/delete.asciidoc[] include::actions-and-connectors/legacy/index.asciidoc[] include::actions-and-connectors/legacy/get.asciidoc[] include::actions-and-connectors/legacy/get_all.asciidoc[] diff --git a/docs/api/actions-and-connectors/create.asciidoc b/docs/api/actions-and-connectors/create.asciidoc index 401f4c5372688a7..d5208b9debfe99c 100644 --- a/docs/api/actions-and-connectors/create.asciidoc +++ b/docs/api/actions-and-connectors/create.asciidoc @@ -1,26 +1,37 @@ [[create-connector-api]] -=== Create connector API +== Create connector API ++++ Create connector ++++ Creates a connector. +[discrete] [[create-connector-api-request]] -==== Request +=== {api-request-title} `POST :/api/actions/connector` `POST :/s//api/actions/connector` +[discrete] +=== {api-prereq-title} + +You must have `all` privileges for the *Actions and Connectors* feature in the +*Management* section of the +<>. + +[discrete] [[create-connector-api-path-params]] -==== Path parameters +=== {api-path-parms-title} `space_id`:: - (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. + (Optional, string) An identifier for the space. If `space_id` is not provided + in the URL, the default space is used. +[discrete] [[create-connector-api-request-body]] -==== Request body +=== {api-request-body-title} `name`:: (Required, string) The display name for the connector. @@ -38,25 +49,27 @@ Creates a connector. + WARNING: Remember these values. You must provide them each time you call the <> API. +[discrete] [[create-connector-api-request-codes]] -==== Response code +=== {api-response-codes-title} `200`:: Indicates a successful call. +[discrete] [[create-connector-api-example]] -==== Example +=== {api-examples-title} [source,sh] -------------------------------------------------- -$ curl -X POST api/actions/connector -H 'kbn-xsrf: true' -H 'Content-Type: application/json' -d ' +POST api/actions/connector { "name": "my-connector", "connector_type_id": ".index", "config": { "index": "test-index" } -}' +} -------------------------------------------------- // KIBANA diff --git a/docs/api/actions-and-connectors/delete.asciidoc b/docs/api/actions-and-connectors/delete.asciidoc index 021a3f7cdf3f7ed..1ef917f58d24f48 100644 --- a/docs/api/actions-and-connectors/delete.asciidoc +++ b/docs/api/actions-and-connectors/delete.asciidoc @@ -1,5 +1,5 @@ [[delete-connector-api]] -=== Delete connector API +== Delete connector API ++++ Delete connector ++++ @@ -8,15 +8,24 @@ Deletes an connector by ID. WARNING: When you delete a connector, _it cannot be recovered_. +[discrete] [[delete-connector-api-request]] -==== Request +=== {api-request-title} `DELETE :/api/actions/connector/` `DELETE :/s//api/actions/connector/` +[discrete] +=== {api-prereq-title} + +You must have `all` privileges for the *Actions and Connectors* feature in the +*Management* section of the +<>. + +[discrete] [[delete-connector-api-path-params]] -==== Path parameters +=== {api-path-parms-title} `id`:: (Required, string) The ID of the connector. @@ -24,16 +33,18 @@ WARNING: When you delete a connector, _it cannot be recovered_. `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. +[discrete] [[delete-connector-api-response-codes]] -==== Response code +=== {api-response-codes-title} `200`:: Indicates a successful call. -==== Example +[discrete] +=== {api-examples-title} [source,sh] -------------------------------------------------- -$ curl -X DELETE api/actions/connector/c55b6eb0-6bad-11eb-9f3b-611eebc6c3ad +DELETE api/actions/connector/c55b6eb0-6bad-11eb-9f3b-611eebc6c3ad -------------------------------------------------- // KIBANA diff --git a/docs/api/actions-and-connectors/get.asciidoc b/docs/api/actions-and-connectors/get.asciidoc index bc6b5fa8f364c5e..2d5cc4edd427641 100644 --- a/docs/api/actions-and-connectors/get.asciidoc +++ b/docs/api/actions-and-connectors/get.asciidoc @@ -1,20 +1,29 @@ [[get-connector-api]] -=== Get connector API +== Get connector API ++++ Get connector ++++ Retrieves a connector by ID. +[discrete] [[get-connector-api-request]] -==== Request +=== {api-request-title} `GET :/api/actions/connector/` `GET :/s//api/actions/connector/` +[discrete] +=== {api-prereq-title} + +You must have `read` privileges for the *Actions and Connectors* feature in the +*Management* section of the +<>. + +[discrete] [[get-connector-api-params]] -==== Path parameters +=== {api-path-parms-title} `id`:: (Required, string) The ID of the connector. @@ -22,18 +31,20 @@ Retrieves a connector by ID. `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. +[discrete] [[get-connector-api-codes]] -==== Response code +=== {api-response-codes-title} `200`:: Indicates a successful call. +[discrete] [[get-connector-api-example]] -==== Example +=== {api-examples-title} [source,sh] -------------------------------------------------- -$ curl -X GET api/actions/connector/c55b6eb0-6bad-11eb-9f3b-611eebc6c3ad +GET api/actions/connector/c55b6eb0-6bad-11eb-9f3b-611eebc6c3ad -------------------------------------------------- // KIBANA @@ -54,4 +65,4 @@ The API returns the following: "is_deprecated": false, "is_missing_secrets": false } --------------------------------------------------- +-------------------------------------------------- \ No newline at end of file diff --git a/docs/api/actions-and-connectors/get_all.asciidoc b/docs/api/actions-and-connectors/get_all.asciidoc index 26bb7247e2ce1c0..b2ebe316fc5b273 100644 --- a/docs/api/actions-and-connectors/get_all.asciidoc +++ b/docs/api/actions-and-connectors/get_all.asciidoc @@ -1,36 +1,47 @@ [[get-all-connectors-api]] -=== Get all connectors API +== Get all connectors API ++++ Get all connectors ++++ Retrieves all connectors. +[discrete] [[get-all-connectors-api-request]] -==== Request +=== {api-request-title} `GET :/api/actions/connectors` `GET :/s//api/actions/connectors` +[discrete] +=== {api-prereq-title} + +You must have `read` privileges for the *Actions and Connectors* feature in the +*Management* section of the +<>. + +[discrete] [[get-all-connectors-api-path-params]] -==== Path parameters +=== {api-path-parms-title} `space_id`:: (Optional, string) An identifier for the space. If `space_id` is not provided in the URL, the default space is used. +[discrete] [[get-all-connectors-api-codes]] -==== Response code +=== {api-response-codes-title} `200`:: Indicates a successful call. +[discrete] [[get-all-connectors-api-example]] -==== Example +=== {api-examples-title} [source,sh] -------------------------------------------------- -$ curl -X GET api/actions/connectors +GET api/actions/connectors -------------------------------------------------- // KIBANA @@ -64,4 +75,4 @@ The API returns the following: ] -------------------------------------------------- -<1> `referenced_by_count` - The number of saved-objects referencing this connector. This value is not calculated if `is_preconfigured: true`. \ No newline at end of file +<1> `referenced_by_count` indicates the number of saved objects that reference the connector. This value is not calculated if `is_preconfigured` is `true`. \ No newline at end of file diff --git a/docs/api/actions-and-connectors/legacy/index.asciidoc b/docs/api/actions-and-connectors/legacy/index.asciidoc index 859dd652de98442..66ecb2ed31119e8 100644 --- a/docs/api/actions-and-connectors/legacy/index.asciidoc +++ b/docs/api/actions-and-connectors/legacy/index.asciidoc @@ -1,4 +1,4 @@ [[actions-and-connectors-legacy-apis]] === Deprecated 7.x APIs -These APIs are deprecated and will be removed as of 8.0. +These APIs are deprecated and will be removed in a future release. diff --git a/docs/api/alerting/create_rule.asciidoc b/docs/api/alerting/create_rule.asciidoc index 59b17c5c3b5e181..79ae7b0c39d6c45 100644 --- a/docs/api/alerting/create_rule.asciidoc +++ b/docs/api/alerting/create_rule.asciidoc @@ -6,8 +6,6 @@ Create {kib} rules. -WARNING: This API supports <> only. - [[create-rule-api-request]] ==== Request @@ -15,6 +13,17 @@ WARNING: This API supports <> only. `POST :/s//api/alerting/rule/` +==== {api-description-title} + +[WARNING] +==== +* This API supports only +<>. +* When you create a rule, it identifies which roles you have at that point in time. +Thereafter, when the rule performs queries, it uses those security privileges. +If a user with different privileges updates the rule, its behavior might change. +==== + [[create-rule-api-path-params]] ==== Path parameters diff --git a/docs/api/alerting/find_rules.asciidoc b/docs/api/alerting/find_rules.asciidoc index 2df8b3522725cda..48c4bb25e0eea4b 100644 --- a/docs/api/alerting/find_rules.asciidoc +++ b/docs/api/alerting/find_rules.asciidoc @@ -43,7 +43,7 @@ NOTE: Rule `params` are stored as a {ref}/flattened.html[flattened field type] a (Optional, array|string) The fields to perform the `simple_query_string` parsed query against. `fields`:: - (Optional, array|string) The fields to return in the `attributes` key of the response. + (Optional, array of strings) The fields to return in the `attributes` key of the response. `sort_field`:: (Optional, string) Sorts the response. Could be a rule field returned in the `attributes` key of the response. diff --git a/docs/api/alerting/legacy/index.asciidoc b/docs/api/alerting/legacy/index.asciidoc index cce2c378bdb5818..48f37c06ff5436a 100644 --- a/docs/api/alerting/legacy/index.asciidoc +++ b/docs/api/alerting/legacy/index.asciidoc @@ -1,7 +1,7 @@ [[alerts-api]] === Deprecated 7.x APIs -These APIs are deprecated and will be removed as of 8.0. +These APIs are deprecated and will be removed in a future release. include::create.asciidoc[leveloffset=+1] include::delete.asciidoc[leveloffset=+1] diff --git a/docs/api/alerting/update_rule.asciidoc b/docs/api/alerting/update_rule.asciidoc index ec82e60a8e879b2..5fb8f17d6ebb518 100644 --- a/docs/api/alerting/update_rule.asciidoc +++ b/docs/api/alerting/update_rule.asciidoc @@ -6,8 +6,6 @@ Update the attributes for an existing rule. -WARNING: This API supports <> only. - [[update-rule-api-request]] ==== Request @@ -15,6 +13,18 @@ WARNING: This API supports <> only. `PUT :/s//api/alerting/rule/` +==== {api-description-title} + +[WARNING] +==== +* This API supports only +<>. +* When you update a rule, it identifies which roles you have at that point in time. +Thereafter, when the rule performs queries, it uses those security privileges. +If you have different privileges than the user that created or most recently +updated the rule, you might change its behavior. +==== + [[update-rule-api-path-params]] ==== Path parameters diff --git a/docs/api/cases/cases-api-add-comment.asciidoc b/docs/api/cases/cases-api-add-comment.asciidoc index df63cc0ecd65f73..203492d6aa6326d 100644 --- a/docs/api/cases/cases-api-add-comment.asciidoc +++ b/docs/api/cases/cases-api-add-comment.asciidoc @@ -119,6 +119,7 @@ The API returns details about the case and its comments. For example: "syncAlerts":false }, "owner": "cases", + "duration": null, "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 b7a97fc9cb1b26f..73c89937466b304 100644 --- a/docs/api/cases/cases-api-create.asciidoc +++ b/docs/api/cases/cases-api-create.asciidoc @@ -205,6 +205,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, "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 abd4e186ff70649..3e94dd56ffa3686 100644 --- a/docs/api/cases/cases-api-find-cases.asciidoc +++ b/docs/api/cases/cases-api-find-cases.asciidoc @@ -125,6 +125,7 @@ The API returns a JSON object listing the retrieved cases. For example: "syncAlerts": true }, "owner": "securitySolution", + "duration": null, "closed_at": null, "closed_by": null, "created_at": "2022-03-29T13:03:23.533Z", @@ -164,6 +165,7 @@ The API returns a JSON object listing the retrieved cases. For example: "syncAlerts": false }, "owner": "cases", + "duration": null, "closed_at": null, "closed_by": null, "created_at": "2022-03-29T11:30:02.658Z", diff --git a/docs/api/cases/cases-api-get-case.asciidoc b/docs/api/cases/cases-api-get-case.asciidoc index 5abb9ecc1903b20..42cf0672065e766 100644 --- a/docs/api/cases/cases-api-get-case.asciidoc +++ b/docs/api/cases/cases-api-get-case.asciidoc @@ -59,6 +59,7 @@ The API returns a JSON object with the retrieved case. For example: "version": "Wzk4LDFd", "comments": [], "totalComment": 0, + "totalAlerts": 0, "closed_at": null, "closed_by": null, "created_at": "2020-03-29T11:30:02.658Z", @@ -90,6 +91,7 @@ The API returns a JSON object with the retrieved case. For example: "syncAlerts": true }, "owner": "securitySolution", + "duration": null, "tags": [ "phishing", "social engineering", diff --git a/docs/api/cases/cases-api-push.asciidoc b/docs/api/cases/cases-api-push.asciidoc index 5b3e4d7c9ef787b..16c411104caed7a 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", + "duration": null, "closed_at": null, "closed_by": null, "created_at": "2022-03-29T11:30:02.658Z", diff --git a/docs/api/cases/cases-api-update-comment.asciidoc b/docs/api/cases/cases-api-update-comment.asciidoc index 020fe403fa7c55f..d00d1eb66ea7c2a 100644 --- a/docs/api/cases/cases-api-update-comment.asciidoc +++ b/docs/api/cases/cases-api-update-comment.asciidoc @@ -106,18 +106,18 @@ The API returns details about the case and its comments. For example: "comments":[{ "id": "8af6ac20-74f6-11ea-b83a-553aecdb28b6", "version": "WzIwNjM3LDFd", - "comment":"That is nothing - Ethan Hunt answered a targeted social media campaign promoting phishy pension schemes to IMF operatives. Even worse, he likes baked beans.", - "type":"user", - "owner":"cases", - "created_at":"2022-03-24T00:37:10.832Z", + "comment": "That is nothing - Ethan Hunt answered a targeted social media campaign promoting phishy pension schemes to IMF operatives. Even worse, he likes baked beans.", + "type": "user", + "owner": "cases", + "created_at": "2022-03-24T00:37:10.832Z", "created_by": { "email": "moneypenny@hms.gov.uk", "full_name": "Ms Moneypenny", "username": "moneypenny" }, - "pushed_at":null, - "pushed_by":null, - "updated_at":"2022-03-24T01:27:06.210Z", + "pushed_at": null, + "pushed_by": null, + "updated_at": "2022-03-24T01:27:06.210Z", "updated_by": { "email": "jbond@hms.gov.uk", "full_name": "James Bond", @@ -125,16 +125,17 @@ The API returns details about the case and its comments. For example: } } ], - "totalAlerts":0, + "totalAlerts": 0, "id": "293f1bc0-74f6-11ea-b83a-553aecdb28b6", "version": "WzIwNjM2LDFd", "totalComment": 1, "title": "This case will self-destruct in 5 seconds", - "tags": ["phishing","social engineering"], + "tags": ["phishing","social engineering"], "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants.", "settings": {"syncAlerts":false}, - "owner": "cases"," - closed_at": null, + "owner": "cases", + "duration": null, + "closed_at": null, "closed_by": null, "created_at": "2022-03-24T00:37:03.906Z", "created_by": { diff --git a/docs/api/cases/cases-api-update.asciidoc b/docs/api/cases/cases-api-update.asciidoc index 7a63d0e8a6a33d9..ebad2feaedff40d 100644 --- a/docs/api/cases/cases-api-update.asciidoc +++ b/docs/api/cases/cases-api-update.asciidoc @@ -226,6 +226,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, "closed_at": null, "closed_by": null, "created_at": "2022-05-13T09:16:17.416Z", diff --git a/docs/api/logstash-configuration-management/list-pipeline.asciidoc b/docs/api/logstash-configuration-management/list-pipeline.asciidoc index d875ea3d95b784c..03f4820ac4758c9 100644 --- a/docs/api/logstash-configuration-management/list-pipeline.asciidoc +++ b/docs/api/logstash-configuration-management/list-pipeline.asciidoc @@ -6,6 +6,8 @@ experimental[] List all centrally-managed Logstash pipelines. +IMPORTANT: Limit the number of pipelines to 10k or fewer. As the number of pipelines nears and surpasses 10k, you may see performance issues on {kib}. + [[logstash-configuration-management-api-list-request]] ==== Request diff --git a/docs/dev-tools/console/console.asciidoc b/docs/dev-tools/console/console.asciidoc index 21334c31011f448..69f81d838c1437c 100644 --- a/docs/dev-tools/console/console.asciidoc +++ b/docs/dev-tools/console/console.asciidoc @@ -12,7 +12,7 @@ To get started, open the main menu, click *Dev Tools*, then click *Console*. [role="screenshot"] image::dev-tools/console/images/console.png["Console"] -NOTE: You cannot to interact with the REST API of {kib} with the Console. +NOTE: **Console** supports only Elasticsearch APIs. You are unable to interact with the {kib} APIs with **Console** and must use curl or another HTTP tool instead. [float] [[console-api]] @@ -137,4 +137,4 @@ shortcuts, click *Help*. If you don’t want to use *Console*, you can disable it by setting `console.ui.enabled` to `false` in your `kibana.yml` configuration file. Changing this setting causes the server to regenerate assets on the next startup, -which might cause a delay before pages start being served. \ No newline at end of file +which might cause a delay before pages start being served. diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 63e104c44b1732c..0d2d69123b5f3f0 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -222,7 +222,7 @@ It also provides a stateful version of it on the start contract. |{kib-repo}blob/{branch}/src/plugins/newsfeed/README.md[newsfeed] |The newsfeed plugin adds a NewsfeedNavButton to the top navigation bar and renders the content in the flyout. -Content is fetched from the remote (https://feeds.elastic.co and https://feeds-staging.elastic.co in dev mode) once a day, with periodic checks if the content needs to be refreshed. All newsfeed content is hosted remotely. +Content is fetched from the remote (https://feeds.elastic.co) once a day, with periodic checks if the content needs to be refreshed. All newsfeed content is hosted remotely. |{kib-repo}blob/{branch}/src/plugins/presentation_util/README.mdx[presentationUtil] @@ -376,6 +376,10 @@ The plugin exposes the static DefaultEditorController class to consume. |The Kibana actions plugin provides a framework to create executable actions. You can: +|{kib-repo}blob/{branch}/x-pack/plugins/aiops/README.md[aiops] +|The plugin provides APIs and components for AIOps features, including the “Explain log rate spikes” UI, maintained by the ML team. + + |{kib-repo}blob/{branch}/x-pack/plugins/alerting/README.md[alerting] |The Kibana Alerting plugin provides a common place to set up rules. You can: diff --git a/docs/maps/asset-tracking-tutorial.asciidoc b/docs/maps/asset-tracking-tutorial.asciidoc index ff62f5c019b74fe..46248c5280b2013 100644 --- a/docs/maps/asset-tracking-tutorial.asciidoc +++ b/docs/maps/asset-tracking-tutorial.asciidoc @@ -8,7 +8,7 @@ In this tutorial, you’ll look at live urban transit data from the city of Port You’ll learn to: -- Use Logstash to ingest the TriMet REST API into Elasticsearch. +- Use {filebeat} to ingest the TriMet REST API into Elasticsearch. - Create a map with layers that visualize asset tracks and last-known locations. - Use symbols and colors to style data values and show which direction an asset is heading. - Set up tracking containment alerts to monitor moving vehicles. @@ -23,137 +23,294 @@ image::maps/images/asset-tracking-tutorial/construction_zones.png[] - If you don’t already have {kib}, set it up with https://www.elastic.co/cloud/elasticsearch-service/signup?baymax=docs-body&elektra=docs[our free trial]. Download the deployment credentials. - Obtain an API key for https://developer.trimet.org/[TriMet web services] at https://developer.trimet.org/appid/registration/. -- https://www.elastic.co/guide/en/logstash/current/getting-started-with-logstash.html[Install Logstash]. +- https://www.elastic.co/guide/en/beats/filebeat/current/filebeat-installation-configuration.html[Install Filebeat]. [float] === Part 1: Ingest the Portland bus data -To get to the fun of visualizing and alerting on Portland buses, you must first create a Logstash pipeline to ingest the TriMet Portland bus data into {es}. +To get to the fun of visualizing and alerting on Portland buses, you must first create a {filebeat} input to ingest the TriMet Portland bus data into {es}. [float] ==== Step 1: Set up an Elasticsearch index . In Kibana, open the main menu, then click *Dev Tools*. -. In *Console*, create the `tri_met_tracks` index: +. In *Console*, create the `tri_met_tracks` index lifecyle policy. This policy will keep the events in the hot data phase for 7 days. The data then moves to the warm phase. After 365 days in the warm phase, the data is deleted. + [source,js] ---------------------------------- -PUT tri_met_tracks +PUT _ilm/policy/tri_met_tracks +{ + "policy": { + "phases": { + "hot": { + "min_age": "0ms", + "actions": { + "rollover": { + "max_primary_shard_size": "50gb", + "max_age": "7d" + }, + "set_priority": { + "priority": 100 + } + } + }, + "warm": { + "min_age": "0d", + "actions": { + "set_priority": { + "priority": 50 + } + } + }, + "delete": { + "min_age": "365d", + "actions": { + "delete": { + "delete_searchable_snapshot": true + } + } + } + } + } +} ---------------------------------- - -. To configure the `tri_met_tracks` index mappings, run: +. In *Console*, create the `tri_met_tracks` index template, which is configured to use datastreams: + [source,js] ---------------------------------- -PUT tri_met_tracks/_mapping +PUT _index_template/tri_met_tracks { - "properties": { - "in_congestion": { - "type": "boolean" + "template": { + "settings": { + "index": { + "lifecycle": { + "name": "tri_met_tracks" + } + } }, - "location": { - "type": "geo_point" + "mappings": { + "_routing": { + "required": false + }, + "numeric_detection": false, + "dynamic_date_formats": [ + "strict_date_optional_time", + "yyyy/MM/dd HH:mm:ss Z||yyyy/MM/dd Z" + ], + "dynamic": true, + "_source": { + "excludes": [], + "includes": [], + "enabled": true + }, + "dynamic_templates": [], + "date_detection": true, + "properties": { + "trimet": { + "type": "object", + "properties": { + "expires": { + "type": "date" + }, + "signMessage": { + "type": "text" + }, + "serviceDate": { + "type": "date" + }, + "loadPercentage": { + "type": "float" + }, + "nextStopSeq": { + "type": "integer" + }, + "source": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "blockID": { + "type": "integer" + }, + "signMessageLong": { + "type": "text" + }, + "lastLocID": { + "type": "integer" + }, + "nextLocID": { + "type": "integer" + }, + "locationInScheduleDay": { + "type": "integer" + }, + "newTrip": { + "type": "boolean" + }, + "direction": { + "type": "integer" + }, + "inCongestion": { + "type": "boolean" + }, + "routeNumber": { + "type": "integer" + }, + "bearing": { + "type": "integer" + }, + "garage": { + "type": "keyword" + }, + "tripID": { + "type": "integer" + }, + "delay": { + "type": "integer" + }, + "extraBlockID": { + "type": "integer" + }, + "messageCode": { + "type": "integer" + }, + "lastStopSeq": { + "type": "integer" + }, + "location": { + "type": "geo_point" + }, + "time": { + "index": true, + "ignore_malformed": false, + "store": false, + "type": "date", + "doc_values": true + }, + "vehicleID": { + "type": "integer" + }, + "offRoute": { + "type": "boolean" + } + } + } + } + } + }, + "index_patterns": [ + "tri_met_tracks*" + ], + "data_stream": { + "hidden": false, + "allow_custom_routing": false + }, + "composed_of": [] +} +---------------------------------- +. In **Console**, add the `tri_met_track` ingest pipeline. ++ +[source,js] +---------------------------------- +PUT _ingest/pipeline/tri_met_tracks +{ + "processors": [ + { + "set": { + "field": "trimet.inCongestion", + "value": "false", + "if": "ctx?.trimet?.inCongestion == null" + } }, - "route": { - "type": "keyword" + { + "convert": { + "field": "trimet.bearing", + "type": "float" + } }, - "time": { - "type": "date", - "format": "epoch_millis" + { + "convert": { + "field": "trimet.inCongestion", + "type": "boolean" + } }, - "type": { - "type": "keyword" + { + "script": { + "source": "ctx['trimet']['location'] = ctx['trimet']['latitude'] + \",\" + ctx['trimet']['longitude']" + } + }, + { + "script": { + "source": "ctx['_id'] = ctx['trimet']['vehicleID'] + \"_\" + ctx['trimet']['time']", + "description": "Generate documentID" + } + }, + { + "remove": { + "field": [ + "message", + "input", + "agent", + "ecs", + "host", + "event", + "trimet.longitude", + "trimet.latitude" + ] + } }, - "vehicle_id": { - "type": "keyword" + { + "set": { + "field": "_index", + "value": "tri_met_tracks" + } } - } + ] } ---------------------------------- [float] -==== Step 2: Start Logstash +==== Step 2: Start {filebeat} -. In your `logstash/config` folder, create the file `trimet-pipeline.conf`. -. Copy the pipeline script into your `trimet-pipeline.conf` file. +. Replace the contents in your `filebeat.yml` file with the following: + [source,yaml] ---------------------------------- -input { - http_poller { - urls => { - trimet => "https://developer.trimet.org/ws/v2/vehicles?appID=" - } - request_timeout => 60 - schedule => { cron => "* * * * * UTC"} - codec => "json" - } -} - -filter { - split { - field => "[resultSet][vehicle]" - } - - if ![resultSet][vehicle][inCongestion] { - mutate { - update => { - "[resultSet][vehicle][inCongestion]" => "false" - } - } - } +filebeat.inputs: +# Fetch trimet bus data every minute. +- type: httpjson + interval: 1m + request.url: "https://developer.trimet.org/ws/v2/vehicles?appID=" + response.split: + target: body.resultSet.vehicle + processors: + - decode_json_fields: + fields: ["message"] + target: "trimet" - mutate { - add_field => { - "bearing" => "%{[resultSet][vehicle][bearing]}" - "in_congestion" => "%{[resultSet][vehicle][inCongestion]}" - "location" => "%{[resultSet][vehicle][latitude]},%{[resultSet][vehicle][longitude]}" - "route" => "%{[resultSet][vehicle][routeNumber]}" - "time" => "%{[resultSet][vehicle][time]}" - "type" => "%{[resultSet][vehicle][type]}" - "vehicle_id" => "%{[resultSet][vehicle][vehicleID]}" - } - remove_field => [ "resultSet", "@version", "@timestamp" ] - } + pipeline: "tri_met_tracks" - mutate { - convert => { - "bearing" => "float" - "in_congestion" => "boolean" - "time" => "integer" - } - } -} -output { - stdout { - codec => rubydebug - } +# ---------------------------- Elastic Cloud Output ---------------------------- +cloud.id: +cloud.auth: - elasticsearch { - cloud_auth => "" - cloud_id => "" - index => "tri_met_tracks" - document_id => "%{[vehicle_id]}_%{[time]}" - } -} ---------------------------------- . Replace `` with your TriMet application id. . Replace `` with your Elastic Cloud deployment credentials. . Replace `` with your {ece}/ece-cloud-id.html[elastic cloud id]. -. Open a terminal window, and then navigate to the Logstash folder. -. In your `logstash` folder, run Logstash with the TriMet pipeline: +. Open a terminal window, and then navigate to the {filebeat} folder. +. In your `filebeat` folder, run {filebeat} with the edited config: + [source,bash] ---------------------------------- -bin/logstash -f config/trimet-pipeline.conf +/bin/filebeat -c filebeat.yml ---------------------------------- -. Wait for Logstash to initialize and confirm data is flowing. You should see messages similar to this: -+ -[role="screenshot"] -image::maps/images/asset-tracking-tutorial/logstash_output.png[] -. Leave the terminal window open and Logstash running throughout this tutorial. +. Wait for {filebeat} to start shipping data to Elastic Cloud. {filebeat} should not produce any output to stdout. + +. Leave the terminal window open and {filebeat} running throughout this tutorial. [float] ==== Step 3: Create a data view for the tri_met_tracks {es} index @@ -162,13 +319,13 @@ image::maps/images/asset-tracking-tutorial/logstash_output.png[] . Click *Create data view*. . Give the data view a name: *tri_met_tracks**. . Click *Next step*. -. Set the *Time field* to *time*. +. Set the *Time field* to *trimet.time*. . Click *Create data view*. {kib} shows the fields in your data view. [role="screenshot"] -image::maps/images/asset-tracking-tutorial/index_pattern.png[] +image::maps/images/asset-tracking-tutorial/data_view.png[] [float] ==== Step 4: Explore the Portland bus data @@ -176,14 +333,14 @@ image::maps/images/asset-tracking-tutorial/index_pattern.png[] . Open the main menu, and click *Discover*. . Set the data view to *tri_met_tracks**. . Open the <>, and set the time range to the last 15 minutes. -. Expand a document and explore some of the fields that you will use later in this tutorial: `bearing`, `in_congestion`, `location`, and `vehicle_id`. +. Expand a document and explore some of the fields that you will use later in this tutorial: `trimet.bearing`, `trimet.inCongestion`, `trimet.location`, and `trimet.vehicleID`. [role="screenshot"] image::maps/images/asset-tracking-tutorial/discover.png[] [float] === Part 2: Build an operational map -It's hard to get an overview of Portland buses by looking at individual events. Let's create a map to show the bus routes and current location for each bus, along with the direction the buses are headed. +It's hard to get an overview of Portland buses by looking at individual events. Let's create a map to show the bus routes and current location for each bus, along with the direction the buses are heading. [float] ==== Step 1: Create your map @@ -204,8 +361,8 @@ Add a layer to show the bus routes for the last 15 minutes. . Click *Tracks*. . Select the *tri_met_tracks** data view. . Define the tracks: -.. Set *Entity* to *vehicle_id*. -.. Set *Sort* to *time*. +.. Set *Entity* to *trimet.vehicleID*. +.. Set *Sort* to *trimet.time*. . Click *Add layer*. . In Layer settings: .. Set *Name* to *Buses*. @@ -227,22 +384,22 @@ Add a layer that uses attributes in the data to set the style and orientation of . Click *Add layer*, and then select *Top Hits per entity*. . Select the *tri_met_tracks** data view. . To display the most recent location per bus: -.. Set *Entity* to *vehicle_id*. +.. Set *Entity* to *trimet.vehicleID*. .. Set *Documents per entity* to 1. -.. Set *Sort field* to *time*. +.. Set *Sort field* to *trimet.time*. .. Set *Sort order* to *descending*. . Click *Add layer*. . Scroll to *Layer Style*. .. Set *Symbol type* to *icon*. .. Set *Icon* to *arrow-es*. .. Set the *Fill color*: -... Select *By value* styling, and set the field to *in_congestion*. +... Select *By value* styling, and set the field to *trimet.inCongestion*. ... Use a *Custom color palette*. ... Set the *Other* color to black. ... Add a green class for *false*, meaning the bus is not in traffic. ... Add a red class for *true*, meaning the bus is in congestion. .. Set *Border width* to 0. -.. Change *Symbol orientation* to use *By value* and the *bearing* field. +.. Change *Symbol orientation* to use *By value* and the *trimet.bearing* field. + [role="screenshot"] image::maps/images/asset-tracking-tutorial/top_hits_layer_style.png[] @@ -265,7 +422,7 @@ Add a layer for construction zones, which you will draw on the map. The construc . Click *Add layer*. . Click *Create index*. -. Set *Index name* to *construction_zones*. +. Set *Index name* to *trimet_construction_zones*. . Click *Create index*. . Draw 2 or 3 construction zones on your map: .. In the toolbar on left side of the map, select the bounding box icon image:maps/images/asset-tracking-tutorial/bounding_box_icon.png[bounding box icon]. @@ -304,8 +461,8 @@ image::maps/images/asset-tracking-tutorial/rule_configuration.png[] . Select the *Tracking containment* rule type. . Set *Select entity*: .. Set *INDEX* to *tri_met_tracks**. -.. Set *BY* to *vehicle_id*. -. Set *Select boundary* *INDEX* to *construction_zones*. +.. Set *BY* to *trimet.vehicleID*. +. Set *Select boundary* *INDEX* to *trimet_construction_zones*. + [role="screenshot"] image::maps/images/asset-tracking-tutorial/tracking_containment_configuration.png[] diff --git a/docs/maps/images/asset-tracking-tutorial/data_view.png b/docs/maps/images/asset-tracking-tutorial/data_view.png new file mode 100644 index 000000000000000..36f010ca927210f Binary files /dev/null and b/docs/maps/images/asset-tracking-tutorial/data_view.png differ diff --git a/docs/maps/images/asset-tracking-tutorial/discover.png b/docs/maps/images/asset-tracking-tutorial/discover.png index d5ea0e55eeddef1..2f7ac73ba0939c3 100644 Binary files a/docs/maps/images/asset-tracking-tutorial/discover.png and b/docs/maps/images/asset-tracking-tutorial/discover.png differ diff --git a/docs/maps/images/asset-tracking-tutorial/index_pattern.png b/docs/maps/images/asset-tracking-tutorial/index_pattern.png deleted file mode 100644 index e1aaecbe62d6570..000000000000000 Binary files a/docs/maps/images/asset-tracking-tutorial/index_pattern.png and /dev/null differ diff --git a/docs/maps/images/asset-tracking-tutorial/top_hits_layer_style.png b/docs/maps/images/asset-tracking-tutorial/top_hits_layer_style.png index b4a869529ad45b6..d77a645160bd2e8 100644 Binary files a/docs/maps/images/asset-tracking-tutorial/top_hits_layer_style.png and b/docs/maps/images/asset-tracking-tutorial/top_hits_layer_style.png differ diff --git a/docs/maps/images/asset-tracking-tutorial/tracking_containment_configuration.png b/docs/maps/images/asset-tracking-tutorial/tracking_containment_configuration.png index 4baf34bb414f16e..87101fee884bfbc 100644 Binary files a/docs/maps/images/asset-tracking-tutorial/tracking_containment_configuration.png and b/docs/maps/images/asset-tracking-tutorial/tracking_containment_configuration.png differ diff --git a/docs/maps/images/inspector.png b/docs/maps/images/inspector.png deleted file mode 100644 index 0d59394caeda21b..000000000000000 Binary files a/docs/maps/images/inspector.png and /dev/null differ diff --git a/docs/maps/images/requests_inspector.png b/docs/maps/images/requests_inspector.png new file mode 100644 index 000000000000000..14bde6ac7c061d0 Binary files /dev/null and b/docs/maps/images/requests_inspector.png differ diff --git a/docs/maps/images/vector_tile_inspector.png b/docs/maps/images/vector_tile_inspector.png new file mode 100644 index 000000000000000..94914802d540ba6 Binary files /dev/null and b/docs/maps/images/vector_tile_inspector.png differ diff --git a/docs/maps/trouble-shooting.asciidoc b/docs/maps/trouble-shooting.asciidoc index 13c8b97c30b3d02..3e4a6dfb42dc1a1 100644 --- a/docs/maps/trouble-shooting.asciidoc +++ b/docs/maps/trouble-shooting.asciidoc @@ -12,10 +12,13 @@ Use the information in this section to inspect Elasticsearch requests and find s [float] === Inspect Elasticsearch requests -Maps uses the {ref}/search-search.html[{es} search API] to get documents and aggregation results from {es}. To troubleshoot these requests, open the Inspector, which shows the most recent requests for each layer. You can switch between different requests using the *Request* dropdown. +Maps uses the {ref}/search-vector-tile-api.html[{es} vector tile search API] and the {ref}/search-search.html[{es} search API] to get documents and aggregation results from {es}. Use *Vector tiles* inspector to view {es} vector tile search API requests. Use *Requests* inspector to view {es} search API requests. [role="screenshot"] -image::maps/images/inspector.png[] +image::maps/images/vector_tile_inspector.png[] + +[role="screenshot"] +image::maps/images/requests_inspector.png[] [float] === Solutions to common problems diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index aa5d9f53359b73b..95003a08b7b0953 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -187,37 +187,37 @@ For example, `20m`, `24h`, `7d`, `1w`. Default: `60s`. [[alert-settings]] ==== Alerting settings -`xpack.alerting.maxEphemeralActionsPerAlert`:: +`xpack.alerting.maxEphemeralActionsPerAlert` {ess-icon}:: Sets the number of actions that will run ephemerally. To use this, enable ephemeral tasks in task manager first with <> -`xpack.alerting.cancelAlertsOnRuleTimeout`:: +`xpack.alerting.cancelAlertsOnRuleTimeout` {ess-icon}:: Specifies whether to skip writing alerts and scheduling actions if rule processing was cancelled due to a timeout. Default: `true`. This setting can be overridden by individual rule types. -`xpack.alerting.rules.minimumScheduleInterval.value`:: +`xpack.alerting.rules.minimumScheduleInterval.value` {ess-icon}:: Specifies the minimum schedule interval for rules. This minimum is applied to all rules created or updated after you set this value. The time is formatted as: + `[s,m,h,d]` + For example, `20m`, `24h`, `7d`. This duration cannot exceed `1d`. Default: `1m`. -`xpack.alerting.rules.minimumScheduleInterval.enforce`:: +`xpack.alerting.rules.minimumScheduleInterval.enforce` {ess-icon}:: Specifies the behavior when a new or changed rule has a schedule interval less than the value defined in `xpack.alerting.rules.minimumScheduleInterval.value`. If `false`, rules with schedules less than the interval will be created but warnings will be logged. If `true`, rules with schedules less than the interval cannot be created. Default: `false`. -`xpack.alerting.rules.run.actions.max`:: +`xpack.alerting.rules.run.actions.max` {ess-icon}:: Specifies the maximum number of actions that a rule can generate each time detection checks run. -`xpack.alerting.rules.run.timeout`:: +`xpack.alerting.rules.run.timeout` {ess-icon}:: Specifies the default timeout for tasks associated with all types of rules. The time is formatted as: + `[ms,s,m,h,d,w,M,Y]` + For example, `20m`, `24h`, `7d`, `1w`. Default: `5m`. -`xpack.alerting.rules.run.ruleTypeOverrides`:: +`xpack.alerting.rules.run.ruleTypeOverrides` {ess-icon}:: Overrides the configs under `xpack.alerting.rules.run` for the rule type with the given ID. List the rule identifier and its settings in an array of objects. + For example: @@ -230,7 +230,7 @@ xpack.alerting.rules.run: timeout: '15m' -- -`xpack.alerting.rules.run.actions.connectorTypeOverrides`:: +`xpack.alerting.rules.run.actions.connectorTypeOverrides` {ess-icon}:: Overrides the configs under `xpack.alerting.rules.run.actions` for the connector type with the given ID. List the connector type identifier and its settings in an array of objects. + For example: diff --git a/docs/settings/apm-settings.asciidoc b/docs/settings/apm-settings.asciidoc index 7441621f441f915..2cfd3169b45a304 100644 --- a/docs/settings/apm-settings.asciidoc +++ b/docs/settings/apm-settings.asciidoc @@ -38,71 +38,69 @@ If you'd like to change any of the default values, copy and paste the relevant settings into your `kibana.yml` configuration file. Changing these settings may disable features of the APM App. -[cols="2*<"] -|=== -| `xpack.apm.maxServiceEnvironments` {ess-icon} - | Maximum number of unique service environments recognized by the UI. Defaults to `100`. +`xpack.apm.maxServiceEnvironments` {ess-icon}:: +Maximum number of unique service environments recognized by the UI. Defaults to `100`. -| `xpack.apm.serviceMapFingerprintBucketSize` {ess-icon} - | Maximum number of unique transaction combinations sampled for generating service map focused on a specific service. Defaults to `100`. +`xpack.apm.serviceMapFingerprintBucketSize` {ess-icon}:: +Maximum number of unique transaction combinations sampled for generating service map focused on a specific service. Defaults to `100`. -| `xpack.apm.serviceMapFingerprintGlobalBucketSize` {ess-icon} - | Maximum number of unique transaction combinations sampled for generating the global service map. Defaults to `100`. +`xpack.apm.serviceMapFingerprintGlobalBucketSize` {ess-icon}:: +Maximum number of unique transaction combinations sampled for generating the global service map. Defaults to `100`. -| `xpack.apm.serviceMapEnabled` {ess-icon} - | Set to `false` to disable service maps. Defaults to `true`. +`xpack.apm.serviceMapEnabled` {ess-icon}:: +Set to `false` to disable service maps. Defaults to `true`. -| `xpack.apm.serviceMapTraceIdBucketSize` {ess-icon} - | Maximum number of trace IDs sampled for generating service map focused on a specific service. Defaults to `65`. +`xpack.apm.serviceMapTraceIdBucketSize` {ess-icon}:: +Maximum number of trace IDs sampled for generating service map focused on a specific service. Defaults to `65`. -| `xpack.apm.serviceMapTraceIdGlobalBucketSize` {ess-icon} - | Maximum number of trace IDs sampled for generating the global service map. Defaults to `6`. +`xpack.apm.serviceMapTraceIdGlobalBucketSize` {ess-icon}:: +Maximum number of trace IDs sampled for generating the global service map. Defaults to `6`. -| `xpack.apm.serviceMapMaxTracesPerRequest` {ess-icon} - | Maximum number of traces per request for generating the global service map. Defaults to `50`. +`xpack.apm.serviceMapMaxTracesPerRequest` {ess-icon}:: +Maximum number of traces per request for generating the global service map. Defaults to `50`. -| `xpack.apm.ui.enabled` {ess-icon} - | Set to `false` to hide the APM app from the main menu. Defaults to `true`. +`xpack.apm.ui.enabled` {ess-icon}:: +Set to `false` to hide the APM app from the main menu. Defaults to `true`. -| `xpack.apm.ui.transactionGroupBucketSize` {ess-icon} - | Number of top transaction groups displayed in the APM app. Defaults to `1000`. +`xpack.apm.ui.transactionGroupBucketSize` {ess-icon}:: +Number of top transaction groups displayed in the APM app. Defaults to `1000`. -| `xpack.apm.ui.maxTraceItems` {ess-icon} - | Maximum number of child items displayed when viewing trace details. Defaults to `1000`. +`xpack.apm.ui.maxTraceItems` {ess-icon}:: +Maximum number of child items displayed when viewing trace details. Defaults to `1000`. -| `xpack.observability.annotations.index` {ess-icon} - | Index name where Observability annotations are stored. Defaults to `observability-annotations`. +`xpack.observability.annotations.index` {ess-icon}:: +Index name where Observability annotations are stored. Defaults to `observability-annotations`. -| `xpack.apm.searchAggregatedTransactions` {ess-icon} - | Enables Transaction histogram metrics. Defaults to `auto` so the UI will use metric indices over transaction indices for transactions if aggregated transactions are found. When set to `always`, additional configuration in APM Server is required. When set to `never` and aggregated transactions are not used. - See {apm-guide-ref}/transaction-metrics.html[Configure transaction metrics] for more information. +`xpack.apm.searchAggregatedTransactions` {ess-icon}:: +Enables Transaction histogram metrics. Defaults to `auto` so the UI will use metric indices over transaction indices for transactions if aggregated transactions are found. When set to `always`, additional configuration in APM Server is required. When set to `never` and aggregated transactions are not used. ++ +See {apm-guide-ref}/transaction-metrics.html[Configure transaction metrics] for more information. -| `xpack.apm.metricsInterval` {ess-icon} - | Sets a `fixed_interval` for date histograms in metrics aggregations. Defaults to `30`. +`xpack.apm.metricsInterval` {ess-icon}:: +Sets a `fixed_interval` for date histograms in metrics aggregations. Defaults to `30`. -| `xpack.apm.agent.migrations.enabled` {ess-icon} - | Set to `false` to disable cloud APM migrations. Defaults to `true`. +`xpack.apm.agent.migrations.enabled` {ess-icon}:: +Set to `false` to disable cloud APM migrations. Defaults to `true`. -| `xpack.apm.indices.error` {ess-icon} - | Matcher for all error indices. Defaults to `logs-apm*,apm-*`. +`xpack.apm.indices.error` {ess-icon}:: +Matcher for all error indices. Defaults to `logs-apm*,apm-*`. -| `xpack.apm.indices.onboarding` {ess-icon} - | Matcher for all onboarding indices. Defaults to `apm-*`. +`xpack.apm.indices.onboarding` {ess-icon}:: +Matcher for all onboarding indices. Defaults to `apm-*`. -| `xpack.apm.indices.span` {ess-icon} - | Matcher for all span indices. Defaults to `traces-apm*,apm-*`. +`xpack.apm.indices.span` {ess-icon}:: +Matcher for all span indices. Defaults to `traces-apm*,apm-*`. -| `xpack.apm.indices.transaction` {ess-icon} - | Matcher for all transaction indices. Defaults to `traces-apm*,apm-*`. +`xpack.apm.indices.transaction` {ess-icon}:: +Matcher for all transaction indices. Defaults to `traces-apm*,apm-*`. -| `xpack.apm.indices.metric` {ess-icon} - | Matcher for all metrics indices. Defaults to `metrics-apm*,apm-*`. +`xpack.apm.indices.metric` {ess-icon}:: +Matcher for all metrics indices. Defaults to `metrics-apm*,apm-*`. -| `xpack.apm.indices.sourcemap` {ess-icon} - | Matcher for all source map indices. Defaults to `apm-*`. +`xpack.apm.indices.sourcemap` {ess-icon}:: +Matcher for all source map indices. Defaults to `apm-*`. -| `xpack.apm.autoCreateApmDataView` {ess-icon} - | Set to `false` to disable the automatic creation of the APM data view when the APM app is opened. Defaults to `true`. -|=== +`xpack.apm.autoCreateApmDataView` {ess-icon}:: +Set to `false` to disable the automatic creation of the APM data view when the APM app is opened. Defaults to `true`. // end::general-apm-settings[] diff --git a/docs/settings/fleet-settings.asciidoc b/docs/settings/fleet-settings.asciidoc index 5ddf45887a53088..ddce9feb3e640bb 100644 --- a/docs/settings/fleet-settings.asciidoc +++ b/docs/settings/fleet-settings.asciidoc @@ -18,104 +18,140 @@ See the {fleet-guide}/index.html[{fleet}] docs for more information. [[general-fleet-settings-kb]] ==== General {fleet} settings -[cols="2*<"] -|=== -| `xpack.fleet.agents.enabled` {ess-icon} - | Set to `true` (default) to enable {fleet}. -|=== +`xpack.fleet.agents.enabled` {ess-icon}:: +Set to `true` (default) to enable {fleet}. + [[fleet-data-visualizer-settings]] ==== {package-manager} settings -[cols="2*<"] -|=== -| `xpack.fleet.registryUrl` - | The address to use to reach the {package-manager} registry. -| `xpack.fleet.registryProxyUrl` - | The proxy address to use to reach the {package-manager} registry if an internet connection is not directly available. - Refer to {fleet-guide}/air-gapped.html[Air-gapped environments] for details. +`xpack.fleet.registryUrl`:: +The address to use to reach the {package-manager} registry. + +`xpack.fleet.registryProxyUrl`:: +The proxy address to use to reach the {package-manager} registry if an internet connection is not directly available. +Refer to {fleet-guide}/air-gapped.html[Air-gapped environments] for details. -|=== ==== {fleet} settings -[cols="2*<"] -|=== -| `xpack.fleet.agents.fleet_server.hosts` - | Hostnames used by {agent} for accessing {fleet-server}. -| `xpack.fleet.agents.elasticsearch.hosts` - | Hostnames used by {agent} for accessing {es}. -| `xpack.fleet.agents.elasticsearch.ca_sha256` - | Hash pin used for certificate verification. The pin is a base64-encoded - string of the SHA-256 fingerprint. -|=== +`xpack.fleet.agents.fleet_server.hosts`:: +Hostnames used by {agent} for accessing {fleet-server}. + +`xpack.fleet.agents.elasticsearch.hosts`:: +Hostnames used by {agent} for accessing {es}. +`xpack.fleet.agents.elasticsearch.ca_sha256`:: +Hash pin used for certificate verification. The pin is a base64-encoded string of the SHA-256 fingerprint. + +[role="child_attributes"] ==== Preconfiguration settings (for advanced use cases) Use these settings to pre-define integrations and agent policies that you want {fleet} to load up by default. -[cols="2* { }); }); + describe('Date value', () => { + test('it should allow the correct type and enforce the _meta.description', () => { + let valueType: SchemaValue = { + type: 'date', + _meta: { + description: 'Some description', + }, + }; + + valueType = { + type: 'keyword', + _meta: { + description: 'Some description', + optional: false, + }, + }; + + valueType = { + // @ts-expect-error because the type does not match + type: 'long', + _meta: { + description: 'Some description', + optional: false, + }, + }; + + valueType = { + type: 'keyword', + _meta: { + description: 'Some description', + // @ts-expect-error optional can't be true when the types don't set the value as optional + optional: true, + }, + }; + + // @ts-expect-error because it's missing the _meta.description + valueType = { type: 'date' }; + expect(valueType).not.toBeUndefined(); // <-- Only to stop the var-not-used complain + }); + test('it should enforce `_meta.optional: true`', () => { + let valueType: SchemaValue = { + type: 'date', + _meta: { + description: 'Some description', + optional: true, + }, + }; + + valueType = { + type: 'date', + _meta: { + description: 'Some description', + // @ts-expect-error because optional can't be false when the value can be undefined + optional: false, + }, + }; + expect(valueType).not.toBeUndefined(); // <-- Only to stop the var-not-used complain + }); + }); + describe('Object value', () => { test('it should allow "pass_through" and enforce the _meta.description', () => { let valueType: SchemaValue<{ a_value: string }> = { @@ -421,6 +481,48 @@ describe('schema types', () => { }; expect(valueType).not.toBeUndefined(); // <-- Only to stop the var-not-used complain }); + + test('it should expect support readonly arrays', () => { + let valueType: SchemaValue> = { + type: 'array', + items: { + properties: { + a_value: { + type: 'keyword', + _meta: { + description: 'Some description', + }, + }, + }, + }, + }; + + valueType = { + type: 'array', + items: { + properties: { + a_value: { + type: 'keyword', + _meta: { + description: 'Some description', + optional: false, + }, + }, + }, + _meta: { + description: 'Description at the object level', + }, + }, + }; + + // @ts-expect-error because it's missing the items definition + valueType = { type: 'array' }; + // @ts-expect-error because it's missing the items definition + valueType = { type: 'array', items: {} }; + // @ts-expect-error because it's missing the items' properties definition + valueType = { type: 'array', items: { properties: {} } }; + expect(valueType).not.toBeUndefined(); // <-- Only to stop the var-not-used complain + }); }); }); diff --git a/packages/analytics/client/src/schema/types.ts b/packages/analytics/client/src/schema/types.ts index 8bac1ceaad62041..35a035bf47b213f 100644 --- a/packages/analytics/client/src/schema/types.ts +++ b/packages/analytics/client/src/schema/types.ts @@ -31,7 +31,7 @@ export type AllowedSchemaTypes = /** * Helper to ensure the declared types match the schema types */ -export type PossibleSchemaTypes = Value extends string +export type PossibleSchemaTypes = Value extends string | Date ? AllowedSchemaStringTypes : Value extends number ? AllowedSchemaNumberTypes @@ -64,8 +64,10 @@ export type SchemaValue = ? // If the Value is unknown (TS can't infer the type), allow any type of schema SchemaArray | SchemaObject | SchemaChildValue : // Otherwise, try to infer the type and enforce the schema - NonNullable extends Array + NonNullable extends Array | ReadonlyArray ? SchemaArray + : NonNullable extends Date + ? SchemaChildValue : NonNullable extends object ? SchemaObject : SchemaChildValue); diff --git a/packages/analytics/shippers/elastic_v3/browser/src/browser_shipper.test.ts b/packages/analytics/shippers/elastic_v3/browser/src/browser_shipper.test.ts index 6fbe8fc16658696..fae3121372193ad 100644 --- a/packages/analytics/shippers/elastic_v3/browser/src/browser_shipper.test.ts +++ b/packages/analytics/shippers/elastic_v3/browser/src/browser_shipper.test.ts @@ -130,7 +130,7 @@ describe('ElasticV3BrowserShipper', () => { { body: '{"timestamp":"2020-01-01T00:00:00.000Z","event_type":"test-event-type","context":{},"properties":{}}\n', headers: { - 'content-type': 'application/x-njson', + 'content-type': 'application/x-ndjson', 'x-elastic-cluster-id': 'UNKNOWN', 'x-elastic-stack-version': '1.2.3', }, @@ -171,7 +171,7 @@ describe('ElasticV3BrowserShipper', () => { { body: '{"timestamp":"2020-01-01T00:00:00.000Z","event_type":"test-event-type","context":{},"properties":{}}\n', headers: { - 'content-type': 'application/x-njson', + 'content-type': 'application/x-ndjson', 'x-elastic-cluster-id': 'UNKNOWN', 'x-elastic-stack-version': '1.2.3', }, @@ -206,7 +206,7 @@ describe('ElasticV3BrowserShipper', () => { { body: '{"timestamp":"2020-01-01T00:00:00.000Z","event_type":"test-event-type","context":{},"properties":{}}\n', headers: { - 'content-type': 'application/x-njson', + 'content-type': 'application/x-ndjson', 'x-elastic-cluster-id': 'UNKNOWN', 'x-elastic-stack-version': '1.2.3', }, @@ -230,7 +230,7 @@ describe('ElasticV3BrowserShipper', () => { { body: '{"timestamp":"2020-01-01T00:00:00.000Z","event_type":"test-event-type","context":{},"properties":{}}\n', headers: { - 'content-type': 'application/x-njson', + 'content-type': 'application/x-ndjson', 'x-elastic-cluster-id': 'UNKNOWN', 'x-elastic-stack-version': '1.2.3', }, @@ -268,7 +268,7 @@ describe('ElasticV3BrowserShipper', () => { { body: '{"timestamp":"2020-01-01T00:00:00.000Z","event_type":"test-event-type","context":{},"properties":{}}\n', headers: { - 'content-type': 'application/x-njson', + 'content-type': 'application/x-ndjson', 'x-elastic-cluster-id': 'UNKNOWN', 'x-elastic-stack-version': '1.2.3', }, diff --git a/packages/analytics/shippers/elastic_v3/browser/src/browser_shipper.ts b/packages/analytics/shippers/elastic_v3/browser/src/browser_shipper.ts index 5607d3d79a13dd2..780d3cd64a5afca 100644 --- a/packages/analytics/shippers/elastic_v3/browser/src/browser_shipper.ts +++ b/packages/analytics/shippers/elastic_v3/browser/src/browser_shipper.ts @@ -97,7 +97,7 @@ export class ElasticV3BrowserShipper implements IShipper { } private async makeRequest(events: Event[]): Promise { - const { status, text, ok } = await fetch(this.url, { + const response = await fetch(this.url, { method: 'POST', body: eventsToNDJSON(events), headers: buildHeaders(this.clusterUuid, this.options.version, this.licenseId), @@ -108,14 +108,17 @@ export class ElasticV3BrowserShipper implements IShipper { if (this.options.debug) { this.initContext.logger.debug( - `[${ElasticV3BrowserShipper.shipperName}]: ${status} - ${await text()}` + `[${ElasticV3BrowserShipper.shipperName}]: ${response.status} - ${await response.text()}` ); } - if (!ok) { - throw new ErrorWithCode(`${status} - ${await text()}`, `${status}`); + if (!response.ok) { + throw new ErrorWithCode( + `${response.status} - ${await response.text()}`, + `${response.status}` + ); } - return `${status}`; + return `${response.status}`; } } diff --git a/packages/analytics/shippers/elastic_v3/common/src/build_headers.test.ts b/packages/analytics/shippers/elastic_v3/common/src/build_headers.test.ts index 468824916ec48f4..ecc350006eef926 100644 --- a/packages/analytics/shippers/elastic_v3/common/src/build_headers.test.ts +++ b/packages/analytics/shippers/elastic_v3/common/src/build_headers.test.ts @@ -12,7 +12,7 @@ describe('buildHeaders', () => { test('builds the headers as expected in the V3 endpoints', () => { expect(buildHeaders('test-cluster', '1.2.3', 'test-license')).toMatchInlineSnapshot(` Object { - "content-type": "application/x-njson", + "content-type": "application/x-ndjson", "x-elastic-cluster-id": "test-cluster", "x-elastic-license-id": "test-license", "x-elastic-stack-version": "1.2.3", @@ -23,7 +23,7 @@ describe('buildHeaders', () => { test('if license is not provided, it skips the license header', () => { expect(buildHeaders('test-cluster', '1.2.3')).toMatchInlineSnapshot(` Object { - "content-type": "application/x-njson", + "content-type": "application/x-ndjson", "x-elastic-cluster-id": "test-cluster", "x-elastic-stack-version": "1.2.3", } diff --git a/packages/analytics/shippers/elastic_v3/common/src/build_headers.ts b/packages/analytics/shippers/elastic_v3/common/src/build_headers.ts index 43126cf9d562988..6f4238b41d8e41f 100644 --- a/packages/analytics/shippers/elastic_v3/common/src/build_headers.ts +++ b/packages/analytics/shippers/elastic_v3/common/src/build_headers.ts @@ -8,7 +8,7 @@ export function buildHeaders(clusterUuid: string, version: string, licenseId?: string) { return { - 'content-type': 'application/x-njson', + 'content-type': 'application/x-ndjson', 'x-elastic-cluster-id': clusterUuid, 'x-elastic-stack-version': version, ...(licenseId && { 'x-elastic-license-id': licenseId }), diff --git a/packages/analytics/shippers/elastic_v3/server/src/server_shipper.test.ts b/packages/analytics/shippers/elastic_v3/server/src/server_shipper.test.ts index ffdfd797437a9fa..d7e8ee379e5285e 100644 --- a/packages/analytics/shippers/elastic_v3/server/src/server_shipper.test.ts +++ b/packages/analytics/shippers/elastic_v3/server/src/server_shipper.test.ts @@ -129,7 +129,7 @@ describe('ElasticV3ServerShipper', () => { { body: '{"timestamp":"2020-01-01T00:00:00.000Z","event_type":"test-event-type","context":{},"properties":{}}\n', headers: { - 'content-type': 'application/x-njson', + 'content-type': 'application/x-ndjson', 'x-elastic-cluster-id': 'UNKNOWN', 'x-elastic-stack-version': '1.2.3', }, @@ -171,7 +171,7 @@ describe('ElasticV3ServerShipper', () => { { body: '{"timestamp":"2020-01-01T00:00:00.000Z","event_type":"test-event-type","context":{},"properties":{}}\n', headers: { - 'content-type': 'application/x-njson', + 'content-type': 'application/x-ndjson', 'x-elastic-cluster-id': 'UNKNOWN', 'x-elastic-stack-version': '1.2.3', }, @@ -208,7 +208,7 @@ describe('ElasticV3ServerShipper', () => { { body: '{"timestamp":"2020-01-01T00:00:00.000Z","event_type":"test-event-type","context":{},"properties":{}}\n', headers: { - 'content-type': 'application/x-njson', + 'content-type': 'application/x-ndjson', 'x-elastic-cluster-id': 'UNKNOWN', 'x-elastic-stack-version': '1.2.3', }, @@ -241,7 +241,7 @@ describe('ElasticV3ServerShipper', () => { ) .join(''), headers: { - 'content-type': 'application/x-njson', + 'content-type': 'application/x-ndjson', 'x-elastic-cluster-id': 'UNKNOWN', 'x-elastic-stack-version': '1.2.3', }, @@ -284,7 +284,7 @@ describe('ElasticV3ServerShipper', () => { { body: '{"timestamp":"2020-01-01T00:00:00.000Z","event_type":"test-event-type","context":{},"properties":{}}\n', headers: { - 'content-type': 'application/x-njson', + 'content-type': 'application/x-ndjson', 'x-elastic-cluster-id': 'UNKNOWN', 'x-elastic-stack-version': '1.2.3', }, @@ -322,7 +322,7 @@ describe('ElasticV3ServerShipper', () => { { body: '{"timestamp":"2020-01-01T00:00:00.000Z","event_type":"test-event-type","context":{},"properties":{}}\n', headers: { - 'content-type': 'application/x-njson', + 'content-type': 'application/x-ndjson', 'x-elastic-cluster-id': 'UNKNOWN', 'x-elastic-stack-version': '1.2.3', }, @@ -357,7 +357,7 @@ describe('ElasticV3ServerShipper', () => { { body: '{"timestamp":"2020-01-01T00:00:00.000Z","event_type":"test-event-type","context":{},"properties":{}}\n', headers: { - 'content-type': 'application/x-njson', + 'content-type': 'application/x-ndjson', 'x-elastic-cluster-id': 'UNKNOWN', 'x-elastic-stack-version': '1.2.3', }, diff --git a/packages/analytics/shippers/elastic_v3/server/src/server_shipper.ts b/packages/analytics/shippers/elastic_v3/server/src/server_shipper.ts index 1c1277f7d7b4b23..dca510d8fe5fa1a 100644 --- a/packages/analytics/shippers/elastic_v3/server/src/server_shipper.ts +++ b/packages/analytics/shippers/elastic_v3/server/src/server_shipper.ts @@ -258,17 +258,21 @@ export class ElasticV3ServerShipper implements IShipper { } private async sendEvents(events: Event[]) { + this.initContext.logger.debug(`Reporting ${events.length} events...`); try { const code = await this.makeRequest(events); this.reportTelemetryCounters(events, { code }); + this.initContext.logger.debug(`Reported ${events.length} events...`); } catch (error) { + this.initContext.logger.debug(`Failed to report ${events.length} events...`); + this.initContext.logger.debug(error); this.reportTelemetryCounters(events, { code: error.code, error }); this.firstTimeOffline = undefined; } } private async makeRequest(events: Event[]): Promise { - const { status, text, ok } = await fetch(this.url, { + const response = await fetch(this.url, { method: 'POST', body: eventsToNDJSON(events), headers: buildHeaders(this.clusterUuid, this.options.version, this.licenseId), @@ -276,15 +280,16 @@ export class ElasticV3ServerShipper implements IShipper { }); if (this.options.debug) { - this.initContext.logger.debug( - `[${ElasticV3ServerShipper.shipperName}]: ${status} - ${await text()}` - ); + this.initContext.logger.debug(`${response.status} - ${await response.text()}`); } - if (!ok) { - throw new ErrorWithCode(`${status} - ${await text()}`, `${status}`); + if (!response.ok) { + throw new ErrorWithCode( + `${response.status} - ${await response.text()}`, + `${response.status}` + ); } - return `${status}`; + return `${response.status}`; } } diff --git a/packages/elastic-eslint-config-kibana/.eslintrc.js b/packages/elastic-eslint-config-kibana/.eslintrc.js index eba5332ca71e388..a12417411a1f8da 100644 --- a/packages/elastic-eslint-config-kibana/.eslintrc.js +++ b/packages/elastic-eslint-config-kibana/.eslintrc.js @@ -196,5 +196,6 @@ module.exports = { '@kbn/eslint/no_this_in_property_initializers': 'error', '@kbn/imports/no_unresolvable_imports': 'error', '@kbn/imports/uniform_imports': 'error', + '@kbn/imports/no_unused_imports': 'error', }, }; diff --git a/packages/kbn-babel-preset/styled_components_files.js b/packages/kbn-babel-preset/styled_components_files.js index a8b1234a406fd9e..53052809b6b2f69 100644 --- a/packages/kbn-babel-preset/styled_components_files.js +++ b/packages/kbn-babel-preset/styled_components_files.js @@ -13,7 +13,7 @@ module.exports = { */ USES_STYLED_COMPONENTS: [ /packages[\/\\]kbn-ui-shared-deps-(npm|src)[\/\\]/, - /src[\/\\]plugins[\/\\](unified_search|kibana_react)[\/\\]/, + /src[\/\\]plugins[\/\\](kibana_react)[\/\\]/, /x-pack[\/\\]plugins[\/\\](apm|beats_management|cases|fleet|infra|lists|observability|osquery|security_solution|timelines|synthetics|ux)[\/\\]/, /x-pack[\/\\]test[\/\\]plugin_functional[\/\\]plugins[\/\\]resolver_test[\/\\]/, ], diff --git a/packages/kbn-bazel-packages/src/bazel_package_dirs.ts b/packages/kbn-bazel-packages/src/bazel_package_dirs.ts index 1027883df10ddc0..80248646f1e6f70 100644 --- a/packages/kbn-bazel-packages/src/bazel_package_dirs.ts +++ b/packages/kbn-bazel-packages/src/bazel_package_dirs.ts @@ -6,9 +6,9 @@ * Side Public License, v 1. */ +import globby from 'globby'; import Path from 'path'; -import globby from 'globby'; import { REPO_ROOT } from '@kbn/utils'; /** diff --git a/packages/kbn-bazel-packages/src/discover_packages.ts b/packages/kbn-bazel-packages/src/discover_packages.ts index db31b54beec75ff..8b78e4e29311886 100644 --- a/packages/kbn-bazel-packages/src/discover_packages.ts +++ b/packages/kbn-bazel-packages/src/discover_packages.ts @@ -9,6 +9,7 @@ import Path from 'path'; import globby from 'globby'; +import normalizePath from 'normalize-path'; import { REPO_ROOT } from '@kbn/utils'; import { asyncMapWithLimit } from '@kbn/std'; @@ -16,7 +17,7 @@ import { BazelPackage } from './bazel_package'; import { BAZEL_PACKAGE_DIRS } from './bazel_package_dirs'; export function discoverBazelPackageLocations(repoRoot: string) { - return globby + const packagesWithPackageJson = globby .sync( BAZEL_PACKAGE_DIRS.map((dir) => `${dir}/*/package.json`), { @@ -24,8 +25,26 @@ export function discoverBazelPackageLocations(repoRoot: string) { absolute: true, } ) + // NOTE: removing x-pack by default for now to prevent a situation where a BUILD.bazel file + // needs to be added at the root of the folder which will make x-pack to be wrongly recognized + // as a Bazel package in that case + .filter((path) => !normalizePath(path).includes('x-pack/package.json')) .sort((a, b) => a.localeCompare(b)) .map((path) => Path.dirname(path)); + + const packagesWithBuildBazel = globby + .sync( + BAZEL_PACKAGE_DIRS.map((dir) => `${dir}/*/BUILD.bazel`), + { + cwd: repoRoot, + absolute: true, + } + ) + .map((path) => Path.dirname(path)); + + // NOTE: only return as discovered packages the ones with a package.json + BUILD.bazel file. + // In the future we should change this to only discover the ones declaring kibana.json. + return packagesWithPackageJson.filter((pkg) => packagesWithBuildBazel.includes(pkg)); } export async function discoverBazelPackages(repoRoot: string = REPO_ROOT) { diff --git a/packages/kbn-dev-utils/src/proc_runner/proc.ts b/packages/kbn-dev-utils/src/proc_runner/proc.ts index c622c46456abfe5..323c1fb674317cc 100644 --- a/packages/kbn-dev-utils/src/proc_runner/proc.ts +++ b/packages/kbn-dev-utils/src/proc_runner/proc.ts @@ -156,5 +156,8 @@ export function startProc(name: string, options: ProcOptions, log: ToolingLog) { outcome$, outcomePromise, stop, + stopWasCalled() { + return stopCalled; + }, }; } diff --git a/packages/kbn-dev-utils/src/proc_runner/proc_runner.ts b/packages/kbn-dev-utils/src/proc_runner/proc_runner.ts index 857a4fcfd475d5c..654a9d1080135a0 100644 --- a/packages/kbn-dev-utils/src/proc_runner/proc_runner.ts +++ b/packages/kbn-dev-utils/src/proc_runner/proc_runner.ts @@ -21,6 +21,7 @@ const noop = () => {}; interface RunOptions extends ProcOptions { wait: true | RegExp; waitTimeout?: number | false; + onEarlyExit?: (msg: string) => void; } /** @@ -47,16 +48,6 @@ export class ProcRunner { /** * Start a process, tracking it by `name` - * @param {String} name - * @param {Object} options - * @property {String} options.cmd executable to run - * @property {Array?} options.args arguments to provide the executable - * @property {String?} options.cwd current working directory for the process - * @property {RegExp|Boolean} options.wait Should start() wait for some time? Use - * `true` will wait until the proc exits, - * a `RegExp` will wait until that log line - * is found - * @return {Promise} */ async run(name: string, options: RunOptions) { const { @@ -66,6 +57,7 @@ export class ProcRunner { wait = false, waitTimeout = 15 * MINUTE, env = process.env, + onEarlyExit, } = options; const cmd = options.cmd === 'node' ? process.execPath : options.cmd; @@ -89,6 +81,25 @@ export class ProcRunner { stdin, }); + if (onEarlyExit) { + proc.outcomePromise + .then( + (code) => { + if (!proc.stopWasCalled()) { + onEarlyExit(`[${name}] exitted early with ${code}`); + } + }, + (error) => { + if (!proc.stopWasCalled()) { + onEarlyExit(`[${name}] exitted early: ${error.message}`); + } + } + ) + .catch((error) => { + throw new Error(`Error handling early exit: ${error.stack}`); + }); + } + try { if (wait instanceof RegExp) { // wait for process to log matching line diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index 79b41112768a643..9c59db0f47f2b21 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -112,6 +112,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { synonyms: `${APP_SEARCH_DOCS}synonyms-guide.html`, webCrawler: `${APP_SEARCH_DOCS}web-crawler.html`, webCrawlerEventLogs: `${APP_SEARCH_DOCS}view-web-crawler-events-logs.html`, + webCrawlerReference: `${APP_SEARCH_DOCS}web-crawler-reference.html`, }, enterpriseSearch: { configuration: `${ENTERPRISE_SEARCH_DOCS}configuration.html`, @@ -443,6 +444,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { guide: `${KIBANA_DOCS}maps.html`, importGeospatialPrivileges: `${KIBANA_DOCS}import-geospatial-data.html#import-geospatial-privileges`, gdalTutorial: `${ELASTIC_WEBSITE_URL}blog/how-to-ingest-geospatial-data-into-elasticsearch-with-gdal`, + termJoinsExample: `${KIBANA_DOCS}terms-join.html#_example_term_join`, }, monitoring: { alertsKibana: `${KIBANA_DOCS}kibana-alerts.html`, @@ -526,6 +528,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { azureRepo: `${ELASTICSEARCH_DOCS}repository-azure.html`, gcsRepo: `${ELASTICSEARCH_DOCS}repository-gcs.html`, hdfsRepo: `${PLUGIN_DOCS}repository-hdfs.html`, + ingestAttachment: `${PLUGIN_DOCS}ingest-attachment.html`, s3Repo: `${ELASTICSEARCH_DOCS}repository-s3.html`, snapshotRestoreRepos: `${ELASTICSEARCH_DOCS}snapshots-register-repository.html`, mapperSize: `${PLUGIN_DOCS}mapper-size-usage.html`, @@ -603,7 +606,6 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { installElasticAgent: `${FLEET_DOCS}install-fleet-managed-elastic-agent.html`, installElasticAgentStandalone: `${FLEET_DOCS}install-standalone-elastic-agent.html`, upgradeElasticAgent: `${FLEET_DOCS}upgrade-elastic-agent.html`, - upgradeElasticAgent712lower: `${FLEET_DOCS}upgrade-elastic-agent.html#upgrade-7.12-lower`, learnMoreBlog: `${ELASTIC_WEBSITE_URL}blog/elastic-agent-and-fleet-make-it-easier-to-integrate-your-systems-with-elastic`, apiKeysLearnMore: `${KIBANA_DOCS}api-keys.html`, onPremRegistry: `${FLEET_DOCS}air-gapped.html`, diff --git a/packages/kbn-doc-links/src/types.ts b/packages/kbn-doc-links/src/types.ts index 645aad3af2bd240..ce6533b93f9e33d 100644 --- a/packages/kbn-doc-links/src/types.ts +++ b/packages/kbn-doc-links/src/types.ts @@ -98,6 +98,7 @@ export interface DocLinks { readonly synonyms: string; readonly webCrawler: string; readonly webCrawlerEventLogs: string; + readonly webCrawlerReference: string; }; readonly enterpriseSearch: { readonly configuration: string; @@ -309,6 +310,7 @@ export interface DocLinks { guide: string; importGeospatialPrivileges: string; gdalTutorial: string; + termJoinsExample: string; }>; readonly monitoring: Record; readonly reporting: Readonly<{ @@ -337,6 +339,7 @@ export interface DocLinks { azureRepo: string; gcsRepo: string; hdfsRepo: string; + ingestAttachment: string; s3Repo: string; snapshotRestoreRepos: string; mapperSize: string; @@ -359,7 +362,6 @@ export interface DocLinks { installElasticAgent: string; installElasticAgentStandalone: string; upgradeElasticAgent: string; - upgradeElasticAgent712lower: string; learnMoreBlog: string; apiKeysLearnMore: string; onPremRegistry: string; diff --git a/packages/kbn-es-archiver/src/cli.ts b/packages/kbn-es-archiver/src/cli.ts index 899a7843a68fc9e..cf871abe6f18fe1 100644 --- a/packages/kbn-es-archiver/src/cli.ts +++ b/packages/kbn-es-archiver/src/cli.ts @@ -33,7 +33,7 @@ export function runCli() { string: ['es-url', 'kibana-url', 'config', 'es-ca', 'kibana-ca'], help: ` --config path to an FTR config file that sets --es-url and --kibana-url - default: ${defaultConfigPath} + default: ${Path.relative(process.cwd(), defaultConfigPath)} --es-url url for Elasticsearch, prefer the --config flag --kibana-url url for Kibana, prefer the --config flag --kibana-ca if Kibana url points to https://localhost we default to the CA from @kbn/dev-utils, customize the CA with this flag diff --git a/packages/kbn-es/src/cluster.js b/packages/kbn-es/src/cluster.js index 50ca9fa91e0aabe..eecaef06be453c4 100644 --- a/packages/kbn-es/src/cluster.js +++ b/packages/kbn-es/src/cluster.js @@ -215,6 +215,25 @@ exports.Cluster = class Cluster { }), ]); }); + + if (options.onEarlyExit) { + this._outcome + .then( + () => { + if (!this._stopCalled) { + options.onEarlyExit(`ES exitted unexpectedly`); + } + }, + (error) => { + if (!this._stopCalled) { + options.onEarlyExit(`ES exitted unexpectedly: ${error.stack}`); + } + } + ) + .catch((error) => { + throw new Error(`failure handling early exit: ${error.stack}`); + }); + } } /** diff --git a/packages/kbn-es/src/cluster_exec_options.ts b/packages/kbn-es/src/cluster_exec_options.ts index 8ef3b23cd8c51df..da21aaf05b1396c 100644 --- a/packages/kbn-es/src/cluster_exec_options.ts +++ b/packages/kbn-es/src/cluster_exec_options.ts @@ -15,4 +15,5 @@ export interface EsClusterExecOptions { password?: string; skipReadyCheck?: boolean; readyTimeout?: number; + onEarlyExit?: (msg: string) => void; } diff --git a/packages/kbn-eslint-plugin-imports/README.md b/packages/kbn-eslint-plugin-imports/README.md index ba22ed240064323..e4708eb3f925f1c 100644 --- a/packages/kbn-eslint-plugin-imports/README.md +++ b/packages/kbn-eslint-plugin-imports/README.md @@ -53,4 +53,8 @@ Config example: This config will find any import of `@kbn/kitchen-sink` which specifically references the `Spatula` or `isSpatula` exports, remove the old exports from the import (potentially removing the entire import), and add a new import after the previous following it's style pointing to the new package. -The auto-fixer here covers the vast majority of import styles in the repository but might not cover everything, including `import * as Namespace from '@kbn/kitchen-sink'`. Imports like this will need to be found and updated manually, though TypeScript should be able to find the vast majority of those. \ No newline at end of file +The auto-fixer here covers the vast majority of import styles in the repository but might not cover everything, including `import * as Namespace from '@kbn/kitchen-sink'`. Imports like this will need to be found and updated manually, though TypeScript should be able to find the vast majority of those. + +## `@kbn/imports/no_unused_imports` + +This rule finds imports that are unused and provides an auto-fix to remove them. When ESLint appears to be running in an editor, as defined by [`helpers/running_in_editor.ts`](src/helpers/running_in_editor.ts), this rule provided suggestions instead of fixes so that the removals are not applied automatically in case you are debugging, returning early, or something else which makes ESLint think that the import is unused when it isn't. On CI and in the pre-commit hook though, this fix will be applied automatically. \ No newline at end of file diff --git a/packages/kbn-eslint-plugin-imports/src/helpers/running_in_editor.ts b/packages/kbn-eslint-plugin-imports/src/helpers/running_in_editor.ts index 3067cc9d1005f0c..02a2b737fda9e86 100644 --- a/packages/kbn-eslint-plugin-imports/src/helpers/running_in_editor.ts +++ b/packages/kbn-eslint-plugin-imports/src/helpers/running_in_editor.ts @@ -7,7 +7,8 @@ */ export const RUNNING_IN_EDITOR = + !process.env.IS_KIBANA_PRECOMIT_HOOK && // vscode sets this in the env for all workers - !!process.env.VSCODE_CWD || - // MacOS sets this for intellij processes, not sure if it works in webstorm but we could expand this check later - !!process.env.__CFBundleIdentifier?.startsWith('com.jetbrains.intellij'); + (!!process.env.VSCODE_CWD || + // MacOS sets this for intellij processes, not sure if it works in webstorm but we could expand this check later + !!process.env.__CFBundleIdentifier?.startsWith('com.jetbrains.intellij')); diff --git a/packages/kbn-eslint-plugin-imports/src/index.ts b/packages/kbn-eslint-plugin-imports/src/index.ts index 24dd819502b5858..ca1050f9ba40608 100644 --- a/packages/kbn-eslint-plugin-imports/src/index.ts +++ b/packages/kbn-eslint-plugin-imports/src/index.ts @@ -10,6 +10,7 @@ export * from './get_import_resolver'; import { NoUnresolvableImportsRule } from './rules/no_unresolvable_imports'; import { UniformImportsRule } from './rules/uniform_imports'; import { ExportsMovedPackagesRule } from './rules/exports_moved_packages'; +import { NoUnusedImportsRule } from './rules/no_unused_imports'; /** * Custom ESLint rules, add `'@kbn/eslint-plugin-imports'` to your eslint config to use them @@ -19,4 +20,5 @@ export const rules = { no_unresolvable_imports: NoUnresolvableImportsRule, uniform_imports: UniformImportsRule, exports_moved_packages: ExportsMovedPackagesRule, + no_unused_imports: NoUnusedImportsRule, }; diff --git a/packages/kbn-eslint-plugin-imports/src/rules/no_unused_imports.test.ts b/packages/kbn-eslint-plugin-imports/src/rules/no_unused_imports.test.ts new file mode 100644 index 000000000000000..3511a3720a90e4d --- /dev/null +++ b/packages/kbn-eslint-plugin-imports/src/rules/no_unused_imports.test.ts @@ -0,0 +1,151 @@ +/* + * 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 { RuleTester } from 'eslint'; +import { NoUnusedImportsRule } from './no_unused_imports'; +import dedent from 'dedent'; + +const fmt = (str: TemplateStringsArray) => dedent(str) + '\n'; + +const tsTester = [ + '@typescript-eslint/parser', + new RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { + sourceType: 'module', + ecmaVersion: 2018, + ecmaFeatures: { + jsx: true, + }, + }, + }), +] as const; + +const babelTester = [ + '@babel/eslint-parser', + new RuleTester({ + parser: require.resolve('@babel/eslint-parser'), + parserOptions: { + sourceType: 'module', + ecmaVersion: 2018, + requireConfigFile: false, + babelOptions: { + presets: ['@kbn/babel-preset/node_preset'], + }, + }, + }), +] as const; + +for (const [name, tester] of [tsTester, babelTester]) { + describe(name, () => { + tester.run('@kbn/imports/no_unused_imports', NoUnusedImportsRule, { + valid: [ + { + filename: 'foo.ts', + code: fmt` + import { foo, bar as Bar } from 'new' + use(foo, Bar) + `, + }, + { + filename: 'foo.ts', + code: fmt` + import Old from 'old' + use(Old) + `, + }, + ], + + invalid: [ + { + filename: 'foo.ts', + code: fmt` + import { foo, bar as Bar } from 'old' + `, + errors: [ + { + line: 1, + message: 'All imports from "old" are unused and should be removed', + }, + ], + output: '', + }, + { + filename: 'foo.ts', + code: fmt` + import type { foo, bar as Bar } from 'old' + `, + errors: [ + { + line: 1, + message: 'All imports from "old" are unused and should be removed', + }, + ], + output: '', + }, + { + filename: 'foo.ts', + code: fmt` + import type { foo, bar as Bar } from 'old' + use(foo) + `, + errors: [ + { + line: 1, + message: 'Bar is unused and should be removed', + }, + ], + output: fmt` + import type { foo, } from 'old' + use(foo) + `, + }, + { + filename: 'foo.ts', + code: fmt` + import type { foo, bar as Bar } from 'old' + use(Bar) + `, + errors: [ + { + line: 1, + message: 'foo is unused and should be removed', + }, + ], + output: fmt` + import type { bar as Bar } from 'old' + use(Bar) + `, + }, + { + filename: 'foo.ts', + code: fmt` + // @ts-expect-error + // @ts-ignore + // foo message + // eslint-disable-next-line some-other-rule + import type { foo, bar as Bar } from 'old' + `, + errors: [ + { + line: 4, + message: `Definition for rule 'some-other-rule' was not found.`, + }, + { + line: 5, + message: 'All imports from "old" are unused and should be removed', + }, + ], + output: fmt` + // foo message + `, + }, + ], + }); + }); +} diff --git a/packages/kbn-eslint-plugin-imports/src/rules/no_unused_imports.ts b/packages/kbn-eslint-plugin-imports/src/rules/no_unused_imports.ts new file mode 100644 index 000000000000000..88153f6f914e56f --- /dev/null +++ b/packages/kbn-eslint-plugin-imports/src/rules/no_unused_imports.ts @@ -0,0 +1,184 @@ +/* + * 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 { Rule, Scope, AST } from 'eslint'; +import type { Comment } from 'estree'; +import * as T from '@babel/types'; +import { TSESTree } from '@typescript-eslint/typescript-estree'; + +import { RUNNING_IN_EDITOR } from '../helpers/running_in_editor'; + +type WithParent = T & { parent?: WithParent }; +type SomeNode = WithParent | TSESTree.Node; +type SomeImportNode = NonNullable>; + +function findImportParent(def: Scope.Definition) { + let cursor: SomeNode | undefined = def.node; + while (cursor) { + if ( + T.isImportDeclaration(cursor) || + cursor.type === TSESTree.AST_NODE_TYPES.ImportDeclaration + ) { + return cursor; + } + cursor = cursor.parent; + } + return; +} + +function isEslintUsed(variable: any) { + return !!variable.eslintUsed; +} + +function findUnusedImportDefs(globalScope: Scope.Scope) { + if (globalScope.type !== 'global') { + throw new Error('pass the global scope'); + } + + const unused = []; + + for (const scope of globalScope.childScopes) { + if (scope.type !== 'module') { + continue; + } + + for (const variable of scope.variables) { + if (variable.references.length > 0 || isEslintUsed(variable)) { + continue; + } + + for (const def of variable.defs) { + const importParent = findImportParent(def); + if (importParent) { + unused.push({ + def, + importParent, + }); + } + } + } + } + + return unused; +} + +function isTsOrEslintIgnore(comment: Comment) { + const value = comment.value.trim(); + return ( + value.startsWith('@ts-ignore') || + value.startsWith('@ts-expect-error') || + value.startsWith('eslint-disable') + ); +} + +export const NoUnusedImportsRule: Rule.RuleModule = { + meta: { + fixable: 'code', + docs: { + url: 'https://github.com/elastic/kibana/blob/main/packages/kbn-eslint-plugin-imports/README.md#kbnimportsno_unused_imports', + }, + }, + create(context) { + const source = context.getSourceCode(); + + function getRange( + nodeA: { loc?: AST.SourceLocation | null }, + nodeB: { loc?: AST.SourceLocation | null } | number = nodeA + ): AST.Range { + if (!nodeA.loc) { + throw new Error('unable to use babel AST nodes without locations'); + } + const nodeBLoc = typeof nodeB === 'number' ? nodeB : nodeB.loc; + if (nodeBLoc == null) { + throw new Error('unable to use babel AST nodes without locations'); + } + return [ + source.getIndexFromLoc(nodeA.loc.start), + typeof nodeBLoc === 'number' + ? source.getIndexFromLoc(nodeA.loc.end) + nodeBLoc + : source.getIndexFromLoc(nodeBLoc.end), + ]; + } + + function report( + node: SomeNode, + msg: string, + fix: (fixer: Rule.RuleFixer) => IterableIterator + ) { + context.report({ + node: node as any, + message: msg, + ...(RUNNING_IN_EDITOR + ? { + suggest: [ + { + desc: 'Remove', + fix, + }, + ], + } + : { + fix, + }), + }); + } + + return { + 'Program:exit': () => { + const unusedByImport = new Map(); + for (const { importParent, def } of findUnusedImportDefs(context.getScope())) { + const group = unusedByImport.get(importParent); + if (group) { + group.push(def); + } else { + unusedByImport.set(importParent, [def]); + } + } + + for (const [importParent, defs] of unusedByImport) { + if (importParent.specifiers.length === defs.length) { + report( + importParent, + `All imports from "${importParent.source.value}" are unused and should be removed`, + function* (fixer) { + // remove entire import including trailing newline if it's detected + const textPlus1 = source.getText(importParent as any, 0, 1); + const range = getRange(importParent, textPlus1.endsWith('\n') ? 1 : importParent); + + // if the import is preceeded by one or more eslint/tslint disable comments then remove them + for (const comment of source.getCommentsBefore(importParent as any)) { + if (isTsOrEslintIgnore(comment)) { + const cRange = getRange(comment); + yield fixer.removeRange( + source.text[cRange[1]] !== '\n' ? cRange : getRange(comment, 1) + ); + } + } + + yield fixer.removeRange(range); + } + ); + } else { + for (const def of defs) { + report( + def.node, + `${def.name.name} is unused and should be removed`, + function* (fixer) { + const nextToken = source.getTokenAfter(def.node); + yield fixer.removeRange( + getRange(def.node, nextToken?.value === ',' ? nextToken : undefined) + ); + } + ); + } + } + } + }, + }; + }, +}; diff --git a/packages/kbn-i18n-react/src/index.tsx b/packages/kbn-i18n-react/src/index.tsx index a6d8ed17d3b66a2..abc9a510cc48ba3 100644 --- a/packages/kbn-i18n-react/src/index.tsx +++ b/packages/kbn-i18n-react/src/index.tsx @@ -6,8 +6,6 @@ * Side Public License, v 1. */ -// eslint-disable-next-line @kbn/eslint/module_migration -import { InjectedIntl as _InjectedIntl, InjectedIntlProps as _InjectedIntlProps } from 'react-intl'; // eslint-disable-next-line @kbn/eslint/module_migration export type { InjectedIntl, InjectedIntlProps } from 'react-intl'; diff --git a/packages/kbn-import-resolver/src/import_resolver.ts b/packages/kbn-import-resolver/src/import_resolver.ts index 44114ce731e28f4..05b69f299a798b1 100644 --- a/packages/kbn-import-resolver/src/import_resolver.ts +++ b/packages/kbn-import-resolver/src/import_resolver.ts @@ -25,9 +25,18 @@ const NODE_MODULE_SEG = Path.sep + 'node_modules' + Path.sep; export class ImportResolver { static create(repoRoot: string) { const pkgMap = new Map(); - for (const dir of discoverBazelPackageLocations(repoRoot)) { - const pkg = JSON.parse(Fs.readFileSync(Path.resolve(dir, 'package.json'), 'utf8')); - pkgMap.set(pkg.name, normalizePath(Path.relative(repoRoot, dir))); + for (const dir of discoverBazelPackageLocations(REPO_ROOT)) { + const relativeBazelPackageDir = Path.relative(REPO_ROOT, dir); + const repoRootBazelPackageDir = Path.resolve(repoRoot, relativeBazelPackageDir); + + if (!Fs.existsSync(Path.resolve(repoRootBazelPackageDir, 'package.json'))) { + continue; + } + + const pkg = JSON.parse( + Fs.readFileSync(Path.resolve(repoRootBazelPackageDir, 'package.json'), 'utf8') + ); + pkgMap.set(pkg.name, normalizePath(relativeBazelPackageDir)); } return new ImportResolver(repoRoot, pkgMap, readPackageMap()); diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 9f73dcd620d3007..561007cb33b23a7 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -1,6 +1,7 @@ pageLoadAssetSize: advancedSettings: 27596 actions: 20000 + aiops: 10000 alerting: 106936 apm: 64385 canvas: 1066647 @@ -57,7 +58,7 @@ pageLoadAssetSize: telemetry: 51957 telemetryManagementSection: 38586 transform: 41007 - triggersActionsUi: 103400 + triggersActionsUi: 105800 #This is temporary. Check https://github.com/elastic/kibana/pull/130710#issuecomment-1119843458 & https://github.com/elastic/kibana/issues/130728 upgradeAssistant: 81241 urlForwarding: 32579 usageCollection: 39762 @@ -104,7 +105,7 @@ pageLoadAssetSize: fieldFormats: 65209 kibanaReact: 74422 share: 71239 - uiActions: 35121 + uiActions: 35121 embeddable: 87309 embeddableEnhanced: 22107 uiActionsEnhanced: 38494 @@ -127,4 +128,5 @@ pageLoadAssetSize: eventAnnotation: 19334 screenshotting: 22870 synthetics: 40958 - expressionXY: 29000 + expressionXY: 31000 + kibanaUsageCollection: 16463 diff --git a/packages/kbn-performance-testing-dataset-extractor/BUILD.bazel b/packages/kbn-performance-testing-dataset-extractor/BUILD.bazel new file mode 100644 index 000000000000000..b58375165352cb8 --- /dev/null +++ b/packages/kbn-performance-testing-dataset-extractor/BUILD.bazel @@ -0,0 +1,122 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library") +load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project") + +PKG_DIRNAME = "kbn-performance-testing-dataset-extractor" +PKG_REQUIRE_NAME = "@kbn/performance-testing-dataset-extractor" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + ], + exclude = [ + "**/*.test.*", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +# In this array place runtime dependencies, including other packages and NPM packages +# which must be available for this code to run. +# +# To reference other packages use: +# "//repo/relative/path/to/package" +# eg. "//packages/kbn-utils" +# +# To reference a NPM package use: +# "@npm//name-of-package" +# eg. "@npm//lodash" +RUNTIME_DEPS = [ + "//packages/kbn-dev-utils", + "//packages/kbn-utils", + "//packages/kbn-tooling-log", + "@npm//@elastic/elasticsearch", +] + +# In this array place dependencies necessary to build the types, which will include the +# :npm_module_types target of other packages and packages from NPM, including @types/* +# packages. +# +# To reference the types for another package use: +# "//repo/relative/path/to/package:npm_module_types" +# eg. "//packages/kbn-utils:npm_module_types" +# +# References to NPM packages work the same as RUNTIME_DEPS +TYPES_DEPS = [ + "//packages/kbn-dev-utils:npm_module_types", + "//packages/kbn-utils:npm_module_types", + "//packages/kbn-tooling-log:npm_module_types", + "@npm//@elastic/elasticsearch", + "@npm//@types/node", + "@npm//@types/jest", +] + +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + "//:tsconfig.bazel.json", + ], +) + +ts_project( + name = "tsc_types", + args = ['--pretty'], + srcs = SRCS, + deps = TYPES_DEPS, + declaration = True, + emit_declaration_only = True, + out_dir = "target_types", + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_DIRNAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = RUNTIME_DEPS + [":target_node"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [":" + PKG_DIRNAME], +) + +filegroup( + name = "build", + srcs = [":npm_module"], + visibility = ["//visibility:public"], +) + +pkg_npm_types( + name = "npm_module_types", + srcs = SRCS, + deps = [":tsc_types"], + package_name = PKG_REQUIRE_NAME, + tsconfig = ":tsconfig", + visibility = ["//visibility:public"], +) + +filegroup( + name = "build_types", + srcs = [":npm_module_types"], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-performance-testing-dataset-extractor/README.md b/packages/kbn-performance-testing-dataset-extractor/README.md new file mode 100644 index 000000000000000..ef5488a82ff2111 --- /dev/null +++ b/packages/kbn-performance-testing-dataset-extractor/README.md @@ -0,0 +1,14 @@ +# @kbn/performance-testing-dataset-extractor + +A library to convert APM traces into JSON format for performance testing. + +## Usage + +``` + node scripts/extract_performance_testing_dataset \ + --journeyName "<_source.labels.journeyName>" \ + --buildId "<_source.labels.testBuildId>" \ + --es-url "" \ + --es-username "" \ + --es-password "" +``` \ No newline at end of file diff --git a/packages/kbn-performance-testing-dataset-extractor/jest.config.js b/packages/kbn-performance-testing-dataset-extractor/jest.config.js new file mode 100644 index 000000000000000..e31a2d799689316 --- /dev/null +++ b/packages/kbn-performance-testing-dataset-extractor/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../..', + roots: ['/packages/kbn-performance-testing-dataset-extractor'], +}; diff --git a/packages/kbn-performance-testing-dataset-extractor/package.json b/packages/kbn-performance-testing-dataset-extractor/package.json new file mode 100644 index 000000000000000..4d637728b28de73 --- /dev/null +++ b/packages/kbn-performance-testing-dataset-extractor/package.json @@ -0,0 +1,11 @@ +{ + "name": "@kbn/performance-testing-dataset-extractor", + "description": "A library to convert APM traces into JSON format for performance testing.", + "private": true, + "version": "1.0.0", + "main": "./target_node/index.js", + "license": "SSPL-1.0 OR Elastic License 2.0", + "kibana": { + "devOnly": true + } +} diff --git a/packages/kbn-performance-testing-dataset-extractor/src/cli.ts b/packages/kbn-performance-testing-dataset-extractor/src/cli.ts new file mode 100644 index 000000000000000..7d16f625e4874a4 --- /dev/null +++ b/packages/kbn-performance-testing-dataset-extractor/src/cli.ts @@ -0,0 +1,81 @@ +/* + * 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. + */ + +/** *********************************************************** + * + * Run `node scripts/extract_performance_testing_dataset --help` for usage information + * + *************************************************************/ + +import { run, createFlagError } from '@kbn/dev-utils'; +import { extractor } from './extractor'; + +export async function runExtractor() { + run( + async ({ log, flags }) => { + const baseURL = flags['es-url']; + if (baseURL && typeof baseURL !== 'string') { + throw createFlagError('--es-url must be a string'); + } + if (!baseURL) { + throw createFlagError('--es-url must be defined'); + } + + const username = flags['es-username']; + if (username && typeof username !== 'string') { + throw createFlagError('--es-username must be a string'); + } + if (!username) { + throw createFlagError('--es-username must be defined'); + } + + const password = flags['es-password']; + if (password && typeof password !== 'string') { + throw createFlagError('--es-password must be a string'); + } + if (!password) { + throw createFlagError('--es-password must be defined'); + } + + const journeyName = flags.journeyName; + if (journeyName && typeof journeyName !== 'string') { + throw createFlagError('--journeyName must be a string'); + } + if (!journeyName) { + throw createFlagError('--journeyName must be defined'); + } + + const buildId = flags.buildId; + if (buildId && typeof buildId !== 'string') { + throw createFlagError('--buildId must be a string'); + } + if (!buildId) { + throw createFlagError('--buildId must be defined'); + } + + return extractor({ + param: { journeyName, buildId }, + client: { baseURL, username, password }, + log, + }); + }, + { + description: `CLI to fetch and normalize APM traces for journey scalability testing`, + flags: { + string: ['journeyName', 'buildId', 'es-url', 'es-username', 'es-password'], + help: ` + --journeyName Single user performance journey name, stored in APM-based document as label: 'labels.journeyName' + --buildId BUILDKITE_JOB_ID or uuid generated locally, stored in APM-based document as label: 'labels.testBuildId' + --es-url url for Elasticsearch (APM cluster) + --es-username username for Elasticsearch (APM cluster) + --es-password password for Elasticsearch (APM cluster) + `, + }, + } + ); +} diff --git a/packages/kbn-performance-testing-dataset-extractor/src/es_client.ts b/packages/kbn-performance-testing-dataset-extractor/src/es_client.ts new file mode 100644 index 000000000000000..53c2e8ba9e8c381 --- /dev/null +++ b/packages/kbn-performance-testing-dataset-extractor/src/es_client.ts @@ -0,0 +1,148 @@ +/* + * 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 { Client } from '@elastic/elasticsearch'; + +interface ClientOptions { + node: string; + username: string; + password: string; +} + +interface Labels { + journeyName: string; + maxUsersCount: string; +} + +interface Request { + method: string; + headers: string; + body?: { original: string }; +} + +interface Response { + status_code: number; +} + +interface Transaction { + id: string; + name: string; + type: string; +} + +export interface Document { + labels: Labels; + character: string; + quote: string; + service: { version: string }; + processor: string; + trace: { id: string }; + '@timestamp': string; + environment: string; + url: { path: string }; + http: { + request: Request; + response: Response; + }; + transaction: Transaction; +} + +export function initClient(options: ClientOptions) { + const client = new Client({ + node: options.node, + auth: { + username: options.username, + password: options.password, + }, + }); + + return { + async getTransactions(buildId: string, journeyName: string) { + const result = await client.search({ + body: { + track_total_hits: true, + sort: [ + { + '@timestamp': { + order: 'desc', + unmapped_type: 'boolean', + }, + }, + ], + size: 10000, + stored_fields: ['*'], + _source: true, + query: { + bool: { + must: [], + filter: [ + { + bool: { + filter: [ + { + bool: { + should: [ + { + match_phrase: { + 'transaction.type': 'request', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [ + { + match_phrase: { + 'processor.event': 'transaction', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [ + { + match_phrase: { + 'labels.testBuildId': buildId, + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [ + { + match_phrase: { + 'labels.journeyName': journeyName, + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + ], + should: [], + must_not: [], + }, + }, + }, + }); + return result?.hits?.hits; + }, + }; +} diff --git a/packages/kbn-performance-testing-dataset-extractor/src/extractor.ts b/packages/kbn-performance-testing-dataset-extractor/src/extractor.ts new file mode 100644 index 000000000000000..cd0dbed9d8e51a8 --- /dev/null +++ b/packages/kbn-performance-testing-dataset-extractor/src/extractor.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 fs from 'fs/promises'; +import { existsSync } from 'fs'; +import path from 'path'; +import { ToolingLog } from '@kbn/tooling-log'; +import { initClient, Document } from './es_client'; + +interface CLIParams { + param: { + journeyName: string; + buildId: string; + }; + client: { + baseURL: string; + username: string; + password: string; + }; + log: ToolingLog; +} + +export const extractor = async ({ param, client, log }: CLIParams) => { + const authOptions = { + node: client.baseURL, + username: client.username, + password: client.password, + }; + const esClient = initClient(authOptions); + const hits = await esClient.getTransactions(param.buildId, param.journeyName); + if (!hits || hits.length === 0) { + log.warning(` + No transactions found with 'labels.testBuildId=${param.buildId}' and 'labels.journeyName=${param.journeyName}' + \nOutput file won't be generated + `); + return; + } + + const source = hits[0]!._source as Document; + const journeyName = source.labels.journeyName || 'Unknown Journey'; + const kibanaVersion = source.service.version; + const maxUsersCount = source.labels.maxUsersCount || '0'; + + const data = hits + .map((hit) => hit!._source as Document) + .map((hit) => { + return { + processor: hit.processor, + traceId: hit.trace.id, + timestamp: hit['@timestamp'], + environment: hit.environment, + request: { + url: { path: hit.url.path }, + headers: hit.http.request.headers, + method: hit.http.request.method, + body: hit.http.request.body ? JSON.parse(hit.http.request.body.original) : '', + }, + response: { statusCode: hit.http.response.status_code }, + transaction: { + id: hit.transaction.id, + name: hit.transaction.name, + type: hit.transaction.type, + }, + }; + }); + + const output = { + journeyName, + kibanaVersion, + maxUsersCount, + traceItems: data, + }; + + const outputDir = path.resolve('target/scalability_traces'); + const fileName = `${output.journeyName.replace(/ /g, '')}-${param.buildId}.json`; + const filePath = path.resolve(outputDir, fileName); + + log.info(`Found ${hits.length} transactions, output file: ${filePath}`); + if (!existsSync(outputDir)) { + await fs.mkdir(outputDir, { recursive: true }); + } + await fs.writeFile(filePath, JSON.stringify(output, null, 2), 'utf8'); +}; diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/index.tsx b/packages/kbn-performance-testing-dataset-extractor/src/index.ts similarity index 73% rename from packages/kbn-shared-ux-components/src/solution_avatar/index.tsx rename to packages/kbn-performance-testing-dataset-extractor/src/index.ts index efc597cbdcb13ed..4e739789d65af8c 100644 --- a/packages/kbn-shared-ux-components/src/solution_avatar/index.tsx +++ b/packages/kbn-performance-testing-dataset-extractor/src/index.ts @@ -5,5 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -export { KibanaSolutionAvatar } from './solution_avatar'; -export type { KibanaSolutionAvatarProps } from './solution_avatar'; + +export { extractor } from './extractor'; +export * from './cli'; diff --git a/packages/kbn-performance-testing-dataset-extractor/tsconfig.json b/packages/kbn-performance-testing-dataset-extractor/tsconfig.json new file mode 100644 index 000000000000000..a8cfc2cceb08b8b --- /dev/null +++ b/packages/kbn-performance-testing-dataset-extractor/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.bazel.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "target_types", + "rootDir": "src", + "stripInternal": false, + "types": [ + "jest", + "node" + ] + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index 97ff65d4f71bc01..5045611c2ac2c7f 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -15289,6 +15289,447 @@ const arrify = value => { module.exports = arrify; +/***/ }), + +/***/ "../../node_modules/asynckit/index.js": +/***/ (function(module, exports, __webpack_require__) { + +module.exports = +{ + parallel : __webpack_require__("../../node_modules/asynckit/parallel.js"), + serial : __webpack_require__("../../node_modules/asynckit/serial.js"), + serialOrdered : __webpack_require__("../../node_modules/asynckit/serialOrdered.js") +}; + + +/***/ }), + +/***/ "../../node_modules/asynckit/lib/abort.js": +/***/ (function(module, exports) { + +// API +module.exports = abort; + +/** + * Aborts leftover active jobs + * + * @param {object} state - current state object + */ +function abort(state) +{ + Object.keys(state.jobs).forEach(clean.bind(state)); + + // reset leftover jobs + state.jobs = {}; +} + +/** + * Cleans up leftover job by invoking abort function for the provided job id + * + * @this state + * @param {string|number} key - job id to abort + */ +function clean(key) +{ + if (typeof this.jobs[key] == 'function') + { + this.jobs[key](); + } +} + + +/***/ }), + +/***/ "../../node_modules/asynckit/lib/async.js": +/***/ (function(module, exports, __webpack_require__) { + +var defer = __webpack_require__("../../node_modules/asynckit/lib/defer.js"); + +// API +module.exports = async; + +/** + * Runs provided callback asynchronously + * even if callback itself is not + * + * @param {function} callback - callback to invoke + * @returns {function} - augmented callback + */ +function async(callback) +{ + var isAsync = false; + + // check if async happened + defer(function() { isAsync = true; }); + + return function async_callback(err, result) + { + if (isAsync) + { + callback(err, result); + } + else + { + defer(function nextTick_callback() + { + callback(err, result); + }); + } + }; +} + + +/***/ }), + +/***/ "../../node_modules/asynckit/lib/defer.js": +/***/ (function(module, exports) { + +module.exports = defer; + +/** + * Runs provided function on next iteration of the event loop + * + * @param {function} fn - function to run + */ +function defer(fn) +{ + var nextTick = typeof setImmediate == 'function' + ? setImmediate + : ( + typeof process == 'object' && typeof process.nextTick == 'function' + ? process.nextTick + : null + ); + + if (nextTick) + { + nextTick(fn); + } + else + { + setTimeout(fn, 0); + } +} + + +/***/ }), + +/***/ "../../node_modules/asynckit/lib/iterate.js": +/***/ (function(module, exports, __webpack_require__) { + +var async = __webpack_require__("../../node_modules/asynckit/lib/async.js") + , abort = __webpack_require__("../../node_modules/asynckit/lib/abort.js") + ; + +// API +module.exports = iterate; + +/** + * Iterates over each job object + * + * @param {array|object} list - array or object (named list) to iterate over + * @param {function} iterator - iterator to run + * @param {object} state - current job status + * @param {function} callback - invoked when all elements processed + */ +function iterate(list, iterator, state, callback) +{ + // store current index + var key = state['keyedList'] ? state['keyedList'][state.index] : state.index; + + state.jobs[key] = runJob(iterator, key, list[key], function(error, output) + { + // don't repeat yourself + // skip secondary callbacks + if (!(key in state.jobs)) + { + return; + } + + // clean up jobs + delete state.jobs[key]; + + if (error) + { + // don't process rest of the results + // stop still active jobs + // and reset the list + abort(state); + } + else + { + state.results[key] = output; + } + + // return salvaged results + callback(error, state.results); + }); +} + +/** + * Runs iterator over provided job element + * + * @param {function} iterator - iterator to invoke + * @param {string|number} key - key/index of the element in the list of jobs + * @param {mixed} item - job description + * @param {function} callback - invoked after iterator is done with the job + * @returns {function|mixed} - job abort function or something else + */ +function runJob(iterator, key, item, callback) +{ + var aborter; + + // allow shortcut if iterator expects only two arguments + if (iterator.length == 2) + { + aborter = iterator(item, async(callback)); + } + // otherwise go with full three arguments + else + { + aborter = iterator(item, key, async(callback)); + } + + return aborter; +} + + +/***/ }), + +/***/ "../../node_modules/asynckit/lib/state.js": +/***/ (function(module, exports) { + +// API +module.exports = state; + +/** + * Creates initial state object + * for iteration over list + * + * @param {array|object} list - list to iterate over + * @param {function|null} sortMethod - function to use for keys sort, + * or `null` to keep them as is + * @returns {object} - initial state object + */ +function state(list, sortMethod) +{ + var isNamedList = !Array.isArray(list) + , initState = + { + index : 0, + keyedList: isNamedList || sortMethod ? Object.keys(list) : null, + jobs : {}, + results : isNamedList ? {} : [], + size : isNamedList ? Object.keys(list).length : list.length + } + ; + + if (sortMethod) + { + // sort array keys based on it's values + // sort object's keys just on own merit + initState.keyedList.sort(isNamedList ? sortMethod : function(a, b) + { + return sortMethod(list[a], list[b]); + }); + } + + return initState; +} + + +/***/ }), + +/***/ "../../node_modules/asynckit/lib/terminator.js": +/***/ (function(module, exports, __webpack_require__) { + +var abort = __webpack_require__("../../node_modules/asynckit/lib/abort.js") + , async = __webpack_require__("../../node_modules/asynckit/lib/async.js") + ; + +// API +module.exports = terminator; + +/** + * Terminates jobs in the attached state context + * + * @this AsyncKitState# + * @param {function} callback - final callback to invoke after termination + */ +function terminator(callback) +{ + if (!Object.keys(this.jobs).length) + { + return; + } + + // fast forward iteration index + this.index = this.size; + + // abort jobs + abort(this); + + // send back results we have so far + async(callback)(null, this.results); +} + + +/***/ }), + +/***/ "../../node_modules/asynckit/parallel.js": +/***/ (function(module, exports, __webpack_require__) { + +var iterate = __webpack_require__("../../node_modules/asynckit/lib/iterate.js") + , initState = __webpack_require__("../../node_modules/asynckit/lib/state.js") + , terminator = __webpack_require__("../../node_modules/asynckit/lib/terminator.js") + ; + +// Public API +module.exports = parallel; + +/** + * Runs iterator over provided array elements in parallel + * + * @param {array|object} list - array or object (named list) to iterate over + * @param {function} iterator - iterator to run + * @param {function} callback - invoked when all elements processed + * @returns {function} - jobs terminator + */ +function parallel(list, iterator, callback) +{ + var state = initState(list); + + while (state.index < (state['keyedList'] || list).length) + { + iterate(list, iterator, state, function(error, result) + { + if (error) + { + callback(error, result); + return; + } + + // looks like it's the last one + if (Object.keys(state.jobs).length === 0) + { + callback(null, state.results); + return; + } + }); + + state.index++; + } + + return terminator.bind(state, callback); +} + + +/***/ }), + +/***/ "../../node_modules/asynckit/serial.js": +/***/ (function(module, exports, __webpack_require__) { + +var serialOrdered = __webpack_require__("../../node_modules/asynckit/serialOrdered.js"); + +// Public API +module.exports = serial; + +/** + * Runs iterator over provided array elements in series + * + * @param {array|object} list - array or object (named list) to iterate over + * @param {function} iterator - iterator to run + * @param {function} callback - invoked when all elements processed + * @returns {function} - jobs terminator + */ +function serial(list, iterator, callback) +{ + return serialOrdered(list, iterator, null, callback); +} + + +/***/ }), + +/***/ "../../node_modules/asynckit/serialOrdered.js": +/***/ (function(module, exports, __webpack_require__) { + +var iterate = __webpack_require__("../../node_modules/asynckit/lib/iterate.js") + , initState = __webpack_require__("../../node_modules/asynckit/lib/state.js") + , terminator = __webpack_require__("../../node_modules/asynckit/lib/terminator.js") + ; + +// Public API +module.exports = serialOrdered; +// sorting helpers +module.exports.ascending = ascending; +module.exports.descending = descending; + +/** + * Runs iterator over provided sorted array elements in series + * + * @param {array|object} list - array or object (named list) to iterate over + * @param {function} iterator - iterator to run + * @param {function} sortMethod - custom sort function + * @param {function} callback - invoked when all elements processed + * @returns {function} - jobs terminator + */ +function serialOrdered(list, iterator, sortMethod, callback) +{ + var state = initState(list, sortMethod); + + iterate(list, iterator, state, function iteratorHandler(error, result) + { + if (error) + { + callback(error, result); + return; + } + + state.index++; + + // are we there yet? + if (state.index < (state['keyedList'] || list).length) + { + iterate(list, iterator, state, iteratorHandler); + return; + } + + // done here + callback(null, state.results); + }); + + return terminator.bind(state, callback); +} + +/* + * -- Sort methods + */ + +/** + * sort helper to sort array elements in ascending order + * + * @param {mixed} a - an item to compare + * @param {mixed} b - an item to compare + * @returns {number} - comparison result + */ +function ascending(a, b) +{ + return a < b ? -1 : a > b ? 1 : 0; +} + +/** + * sort helper to sort array elements in descending order + * + * @param {mixed} a - an item to compare + * @param {mixed} b - an item to compare + * @returns {number} - comparison result + */ +function descending(a, b) +{ + return -1 * ascending(a, b); +} + + /***/ }), /***/ "../../node_modules/axios/index.js": @@ -15314,12 +15755,15 @@ var httpFollow = __webpack_require__("../../node_modules/follow-redirects/index. var httpsFollow = __webpack_require__("../../node_modules/follow-redirects/index.js").https; var url = __webpack_require__("url"); var zlib = __webpack_require__("zlib"); -var pkg = __webpack_require__("../../node_modules/axios/package.json"); -var createError = __webpack_require__("../../node_modules/axios/lib/core/createError.js"); -var enhanceError = __webpack_require__("../../node_modules/axios/lib/core/enhanceError.js"); +var VERSION = __webpack_require__("../../node_modules/axios/lib/env/data.js").version; +var transitionalDefaults = __webpack_require__("../../node_modules/axios/lib/defaults/transitional.js"); +var AxiosError = __webpack_require__("../../node_modules/axios/lib/core/AxiosError.js"); +var CanceledError = __webpack_require__("../../node_modules/axios/lib/cancel/CanceledError.js"); var isHttps = /https:?/; +var supportedProtocols = [ 'http:', 'https:', 'file:' ]; + /** * * @param {http.ClientRequestArgs} options @@ -15348,23 +15792,51 @@ function setProxy(options, proxy, location) { /*eslint consistent-return:0*/ module.exports = function httpAdapter(config) { return new Promise(function dispatchHttpRequest(resolvePromise, rejectPromise) { + var onCanceled; + function done() { + if (config.cancelToken) { + config.cancelToken.unsubscribe(onCanceled); + } + + if (config.signal) { + config.signal.removeEventListener('abort', onCanceled); + } + } var resolve = function resolve(value) { + done(); resolvePromise(value); }; + var rejected = false; var reject = function reject(value) { + done(); + rejected = true; rejectPromise(value); }; var data = config.data; var headers = config.headers; + var headerNames = {}; + + Object.keys(headers).forEach(function storeLowerName(name) { + headerNames[name.toLowerCase()] = name; + }); // Set User-Agent (required by some servers) - // Only set header if it hasn't been set in config // See https://github.com/axios/axios/issues/69 - if (!headers['User-Agent'] && !headers['user-agent']) { - headers['User-Agent'] = 'axios/' + pkg.version; + if ('user-agent' in headerNames) { + // User-Agent is specified; handle case where no UA header is desired + if (!headers[headerNames['user-agent']]) { + delete headers[headerNames['user-agent']]; + } + // Otherwise, use specified value + } else { + // Only set header if it hasn't been set in config + headers['User-Agent'] = 'axios/' + VERSION; } - if (data && !utils.isStream(data)) { + // support for https://www.npmjs.com/package/form-data api + if (utils.isFormData(data) && utils.isFunction(data.getHeaders)) { + Object.assign(headers, data.getHeaders()); + } else if (data && !utils.isStream(data)) { if (Buffer.isBuffer(data)) { // Nothing to do... } else if (utils.isArrayBuffer(data)) { @@ -15372,14 +15844,25 @@ module.exports = function httpAdapter(config) { } else if (utils.isString(data)) { data = Buffer.from(data, 'utf-8'); } else { - return reject(createError( + return reject(new AxiosError( 'Data after transformation must be a string, an ArrayBuffer, a Buffer, or a Stream', + AxiosError.ERR_BAD_REQUEST, + config + )); + } + + if (config.maxBodyLength > -1 && data.length > config.maxBodyLength) { + return reject(new AxiosError( + 'Request body larger than maxBodyLength limit', + AxiosError.ERR_BAD_REQUEST, config )); } // Add Content-Length header if data exists - headers['Content-Length'] = data.length; + if (!headerNames['content-length']) { + headers['Content-Length'] = data.length; + } } // HTTP basic authentication @@ -15393,7 +15876,15 @@ module.exports = function httpAdapter(config) { // Parse url var fullPath = buildFullPath(config.baseURL, config.url); var parsed = url.parse(fullPath); - var protocol = parsed.protocol || 'http:'; + var protocol = parsed.protocol || supportedProtocols[0]; + + if (supportedProtocols.indexOf(protocol) === -1) { + return reject(new AxiosError( + 'Unsupported protocol ' + protocol, + AxiosError.ERR_BAD_REQUEST, + config + )); + } if (!auth && parsed.auth) { var urlAuth = parsed.auth.split(':'); @@ -15402,13 +15893,23 @@ module.exports = function httpAdapter(config) { auth = urlUsername + ':' + urlPassword; } - if (auth) { - delete headers.Authorization; + if (auth && headerNames.authorization) { + delete headers[headerNames.authorization]; } var isHttpsRequest = isHttps.test(protocol); var agent = isHttpsRequest ? config.httpsAgent : config.httpAgent; + try { + buildURL(parsed.path, config.params, config.paramsSerializer).replace(/^\?/, ''); + } catch (err) { + var customErr = new Error(err.message); + customErr.config = config; + customErr.url = config.url; + customErr.exists = true; + reject(customErr); + } + var options = { path: buildURL(parsed.path, config.params, config.paramsSerializer).replace(/^\?/, ''), method: config.method.toUpperCase(), @@ -15488,6 +15989,9 @@ module.exports = function httpAdapter(config) { if (config.maxRedirects) { options.maxRedirects = config.maxRedirects; } + if (config.beforeRedirect) { + options.beforeRedirect = config.beforeRedirect; + } transport = isHttpsProxy ? httpsFollow : httpFollow; } @@ -15495,6 +15999,10 @@ module.exports = function httpAdapter(config) { options.maxBodyLength = config.maxBodyLength; } + if (config.insecureHTTPParser) { + options.insecureHTTPParser = config.insecureHTTPParser; + } + // Create the request var req = transport.request(options, function handleResponse(res) { if (req.aborted) return; @@ -15535,32 +16043,52 @@ module.exports = function httpAdapter(config) { settle(resolve, reject, response); } else { var responseBuffer = []; + var totalResponseBytes = 0; stream.on('data', function handleStreamData(chunk) { responseBuffer.push(chunk); + totalResponseBytes += chunk.length; // make sure the content length is not over the maxContentLength if specified - if (config.maxContentLength > -1 && Buffer.concat(responseBuffer).length > config.maxContentLength) { + if (config.maxContentLength > -1 && totalResponseBytes > config.maxContentLength) { + // stream.destoy() emit aborted event before calling reject() on Node.js v16 + rejected = true; stream.destroy(); - reject(createError('maxContentLength size of ' + config.maxContentLength + ' exceeded', - config, null, lastRequest)); + reject(new AxiosError('maxContentLength size of ' + config.maxContentLength + ' exceeded', + AxiosError.ERR_BAD_RESPONSE, config, lastRequest)); + } + }); + + stream.on('aborted', function handlerStreamAborted() { + if (rejected) { + return; } + stream.destroy(); + reject(new AxiosError( + 'maxContentLength size of ' + config.maxContentLength + ' exceeded', + AxiosError.ERR_BAD_RESPONSE, + config, + lastRequest + )); }); stream.on('error', function handleStreamError(err) { if (req.aborted) return; - reject(enhanceError(err, config, null, lastRequest)); + reject(AxiosError.from(err, null, config, lastRequest)); }); stream.on('end', function handleStreamEnd() { - var responseData = Buffer.concat(responseBuffer); - if (config.responseType !== 'arraybuffer') { - responseData = responseData.toString(config.responseEncoding); - if (!config.responseEncoding || config.responseEncoding === 'utf8') { - responseData = utils.stripBOM(responseData); + try { + var responseData = responseBuffer.length === 1 ? responseBuffer[0] : Buffer.concat(responseBuffer); + if (config.responseType !== 'arraybuffer') { + responseData = responseData.toString(config.responseEncoding); + if (!config.responseEncoding || config.responseEncoding === 'utf8') { + responseData = utils.stripBOM(responseData); + } } + response.data = responseData; + } catch (err) { + reject(AxiosError.from(err, null, config, response.request, response)); } - - response.data = responseData; settle(resolve, reject, response); }); } @@ -15568,37 +16096,71 @@ module.exports = function httpAdapter(config) { // Handle errors req.on('error', function handleRequestError(err) { - if (req.aborted && err.code !== 'ERR_FR_TOO_MANY_REDIRECTS') return; - reject(enhanceError(err, config, null, req)); + // @todo remove + // if (req.aborted && err.code !== AxiosError.ERR_FR_TOO_MANY_REDIRECTS) return; + reject(AxiosError.from(err, null, config, req)); + }); + + // set tcp keep alive to prevent drop connection by peer + req.on('socket', function handleRequestSocket(socket) { + // default interval of sending ack packet is 1 minute + socket.setKeepAlive(true, 1000 * 60); }); // Handle request timeout if (config.timeout) { + // This is forcing a int timeout to avoid problems if the `req` interface doesn't handle other types. + var timeout = parseInt(config.timeout, 10); + + if (isNaN(timeout)) { + reject(new AxiosError( + 'error trying to parse `config.timeout` to int', + AxiosError.ERR_BAD_OPTION_VALUE, + config, + req + )); + + return; + } + // Sometime, the response will be very slow, and does not respond, the connect event will be block by event loop system. // And timer callback will be fired, and abort() will be invoked before connection, then get "socket hang up" and code ECONNRESET. // At this time, if we have a large number of request, nodejs will hang up some socket on background. and the number will up and up. // And then these socket which be hang up will devoring CPU little by little. // ClientRequest.setTimeout will be fired on the specify milliseconds, and can make sure that abort() will be fired after connect. - req.setTimeout(config.timeout, function handleRequestTimeout() { + req.setTimeout(timeout, function handleRequestTimeout() { req.abort(); - reject(createError('timeout of ' + config.timeout + 'ms exceeded', config, 'ECONNABORTED', req)); + var transitional = config.transitional || transitionalDefaults; + reject(new AxiosError( + 'timeout of ' + timeout + 'ms exceeded', + transitional.clarifyTimeoutError ? AxiosError.ETIMEDOUT : AxiosError.ECONNABORTED, + config, + req + )); }); } - if (config.cancelToken) { + if (config.cancelToken || config.signal) { // Handle cancellation - config.cancelToken.promise.then(function onCanceled(cancel) { + // eslint-disable-next-line func-names + onCanceled = function(cancel) { if (req.aborted) return; req.abort(); - reject(cancel); - }); + reject(!cancel || (cancel && cancel.type) ? new CanceledError() : cancel); + }; + + config.cancelToken && config.cancelToken.subscribe(onCanceled); + if (config.signal) { + config.signal.aborted ? onCanceled() : config.signal.addEventListener('abort', onCanceled); + } } + // Send the request if (utils.isStream(data)) { data.on('error', function handleStreamError(err) { - reject(enhanceError(err, config, null, req)); + reject(AxiosError.from(err, config, null, req)); }).pipe(req); } else { req.end(data); @@ -15622,14 +16184,28 @@ var buildURL = __webpack_require__("../../node_modules/axios/lib/helpers/buildUR var buildFullPath = __webpack_require__("../../node_modules/axios/lib/core/buildFullPath.js"); var parseHeaders = __webpack_require__("../../node_modules/axios/lib/helpers/parseHeaders.js"); var isURLSameOrigin = __webpack_require__("../../node_modules/axios/lib/helpers/isURLSameOrigin.js"); -var createError = __webpack_require__("../../node_modules/axios/lib/core/createError.js"); +var transitionalDefaults = __webpack_require__("../../node_modules/axios/lib/defaults/transitional.js"); +var AxiosError = __webpack_require__("../../node_modules/axios/lib/core/AxiosError.js"); +var CanceledError = __webpack_require__("../../node_modules/axios/lib/cancel/CanceledError.js"); +var parseProtocol = __webpack_require__("../../node_modules/axios/lib/helpers/parseProtocol.js"); module.exports = function xhrAdapter(config) { return new Promise(function dispatchXhrRequest(resolve, reject) { var requestData = config.data; var requestHeaders = config.headers; + var responseType = config.responseType; + var onCanceled; + function done() { + if (config.cancelToken) { + config.cancelToken.unsubscribe(onCanceled); + } - if (utils.isFormData(requestData)) { + if (config.signal) { + config.signal.removeEventListener('abort', onCanceled); + } + } + + if (utils.isFormData(requestData) && utils.isStandardBrowserEnv()) { delete requestHeaders['Content-Type']; // Let the browser set it } @@ -15643,28 +16219,20 @@ module.exports = function xhrAdapter(config) { } var fullPath = buildFullPath(config.baseURL, config.url); + request.open(config.method.toUpperCase(), buildURL(fullPath, config.params, config.paramsSerializer), true); // Set the request timeout in MS request.timeout = config.timeout; - // Listen for ready state - request.onreadystatechange = function handleLoad() { - if (!request || request.readyState !== 4) { - return; - } - - // The request errored out and we didn't get a response, this will be - // handled by onerror instead - // With one exception: request that using file: protocol, most browsers - // will return status as 0 even though it's a successful request - if (request.status === 0 && !(request.responseURL && request.responseURL.indexOf('file:') === 0)) { + function onloadend() { + if (!request) { return; } - // Prepare the response var responseHeaders = 'getAllResponseHeaders' in request ? parseHeaders(request.getAllResponseHeaders()) : null; - var responseData = !config.responseType || config.responseType === 'text' ? request.responseText : request.response; + var responseData = !responseType || responseType === 'text' || responseType === 'json' ? + request.responseText : request.response; var response = { data: responseData, status: request.status, @@ -15674,11 +16242,40 @@ module.exports = function xhrAdapter(config) { request: request }; - settle(resolve, reject, response); + settle(function _resolve(value) { + resolve(value); + done(); + }, function _reject(err) { + reject(err); + done(); + }, response); // Clean up request request = null; - }; + } + + if ('onloadend' in request) { + // Use onloadend if available + request.onloadend = onloadend; + } else { + // Listen for ready state to emulate onloadend + request.onreadystatechange = function handleLoad() { + if (!request || request.readyState !== 4) { + return; + } + + // The request errored out and we didn't get a response, this will be + // handled by onerror instead + // With one exception: request that using file: protocol, most browsers + // will return status as 0 even though it's a successful request + if (request.status === 0 && !(request.responseURL && request.responseURL.indexOf('file:') === 0)) { + return; + } + // readystate handler is calling before onerror or ontimeout handlers, + // so we should call onloadend on the next 'tick' + setTimeout(onloadend); + }; + } // Handle browser request cancellation (as opposed to a manual cancellation) request.onabort = function handleAbort() { @@ -15686,7 +16283,7 @@ module.exports = function xhrAdapter(config) { return; } - reject(createError('Request aborted', config, 'ECONNABORTED', request)); + reject(new AxiosError('Request aborted', AxiosError.ECONNABORTED, config, request)); // Clean up request request = null; @@ -15696,7 +16293,7 @@ module.exports = function xhrAdapter(config) { request.onerror = function handleError() { // Real errors are hidden from us by the browser // onerror should only fire if it's a network error - reject(createError('Network Error', config, null, request)); + reject(new AxiosError('Network Error', AxiosError.ERR_NETWORK, config, request, request)); // Clean up request request = null; @@ -15704,11 +16301,15 @@ module.exports = function xhrAdapter(config) { // Handle timeout request.ontimeout = function handleTimeout() { - var timeoutErrorMessage = 'timeout of ' + config.timeout + 'ms exceeded'; + var timeoutErrorMessage = config.timeout ? 'timeout of ' + config.timeout + 'ms exceeded' : 'timeout exceeded'; + var transitional = config.transitional || transitionalDefaults; if (config.timeoutErrorMessage) { timeoutErrorMessage = config.timeoutErrorMessage; } - reject(createError(timeoutErrorMessage, config, 'ECONNABORTED', + reject(new AxiosError( + timeoutErrorMessage, + transitional.clarifyTimeoutError ? AxiosError.ETIMEDOUT : AxiosError.ECONNABORTED, + config, request)); // Clean up request @@ -15748,16 +16349,8 @@ module.exports = function xhrAdapter(config) { } // Add responseType to request if needed - if (config.responseType) { - try { - request.responseType = config.responseType; - } catch (e) { - // Expected DOMException thrown by browsers not compatible XMLHttpRequest Level 2. - // But, this can be suppressed for 'json' type as it can be parsed by default 'transformResponse' function. - if (config.responseType !== 'json') { - throw e; - } - } + if (responseType && responseType !== 'json') { + request.responseType = config.responseType; } // Handle progress if needed @@ -15770,24 +16363,36 @@ module.exports = function xhrAdapter(config) { request.upload.addEventListener('progress', config.onUploadProgress); } - if (config.cancelToken) { + if (config.cancelToken || config.signal) { // Handle cancellation - config.cancelToken.promise.then(function onCanceled(cancel) { + // eslint-disable-next-line func-names + onCanceled = function(cancel) { if (!request) { return; } - + reject(!cancel || (cancel && cancel.type) ? new CanceledError() : cancel); request.abort(); - reject(cancel); - // Clean up request request = null; - }); + }; + + config.cancelToken && config.cancelToken.subscribe(onCanceled); + if (config.signal) { + config.signal.aborted ? onCanceled() : config.signal.addEventListener('abort', onCanceled); + } } if (!requestData) { requestData = null; } + var protocol = parseProtocol(fullPath); + + if (protocol && [ 'http', 'https', 'file' ].indexOf(protocol) === -1) { + reject(new AxiosError('Unsupported protocol ' + protocol + ':', AxiosError.ERR_BAD_REQUEST, config)); + return; + } + + // Send the request request.send(requestData); }); @@ -15806,7 +16411,7 @@ var utils = __webpack_require__("../../node_modules/axios/lib/utils.js"); var bind = __webpack_require__("../../node_modules/axios/lib/helpers/bind.js"); var Axios = __webpack_require__("../../node_modules/axios/lib/core/Axios.js"); var mergeConfig = __webpack_require__("../../node_modules/axios/lib/core/mergeConfig.js"); -var defaults = __webpack_require__("../../node_modules/axios/lib/defaults.js"); +var defaults = __webpack_require__("../../node_modules/axios/lib/defaults/index.js"); /** * Create an instance of Axios @@ -15824,6 +16429,11 @@ function createInstance(defaultConfig) { // Copy context to instance utils.extend(instance, context); + // Factory for creating new instances + instance.create = function create(instanceConfig) { + return createInstance(mergeConfig(defaultConfig, instanceConfig)); + }; + return instance; } @@ -15833,15 +16443,18 @@ var axios = createInstance(defaults); // Expose Axios class to allow class inheritance axios.Axios = Axios; -// Factory for creating new instances -axios.create = function create(instanceConfig) { - return createInstance(mergeConfig(axios.defaults, instanceConfig)); -}; - // Expose Cancel & CancelToken -axios.Cancel = __webpack_require__("../../node_modules/axios/lib/cancel/Cancel.js"); +axios.CanceledError = __webpack_require__("../../node_modules/axios/lib/cancel/CanceledError.js"); axios.CancelToken = __webpack_require__("../../node_modules/axios/lib/cancel/CancelToken.js"); axios.isCancel = __webpack_require__("../../node_modules/axios/lib/cancel/isCancel.js"); +axios.VERSION = __webpack_require__("../../node_modules/axios/lib/env/data.js").version; +axios.toFormData = __webpack_require__("../../node_modules/axios/lib/helpers/toFormData.js"); + +// Expose AxiosError class +axios.AxiosError = __webpack_require__("../../node_modules/axios/lib/core/AxiosError.js"); + +// alias for CanceledError for backward compatibility +axios.Cancel = axios.CanceledError; // Expose all/spread axios.all = function all(promises) { @@ -15858,33 +16471,6 @@ module.exports = axios; module.exports.default = axios; -/***/ }), - -/***/ "../../node_modules/axios/lib/cancel/Cancel.js": -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -/** - * A `Cancel` is an object that is thrown when an operation is canceled. - * - * @class - * @param {string=} message The message. - */ -function Cancel(message) { - this.message = message; -} - -Cancel.prototype.toString = function toString() { - return 'Cancel' + (this.message ? ': ' + this.message : ''); -}; - -Cancel.prototype.__CANCEL__ = true; - -module.exports = Cancel; - - /***/ }), /***/ "../../node_modules/axios/lib/cancel/CancelToken.js": @@ -15893,7 +16479,7 @@ module.exports = Cancel; "use strict"; -var Cancel = __webpack_require__("../../node_modules/axios/lib/cancel/Cancel.js"); +var CanceledError = __webpack_require__("../../node_modules/axios/lib/cancel/CanceledError.js"); /** * A `CancelToken` is an object that can be used to request cancellation of an operation. @@ -15907,24 +16493,55 @@ function CancelToken(executor) { } var resolvePromise; + this.promise = new Promise(function promiseExecutor(resolve) { resolvePromise = resolve; }); var token = this; + + // eslint-disable-next-line func-names + this.promise.then(function(cancel) { + if (!token._listeners) return; + + var i; + var l = token._listeners.length; + + for (i = 0; i < l; i++) { + token._listeners[i](cancel); + } + token._listeners = null; + }); + + // eslint-disable-next-line func-names + this.promise.then = function(onfulfilled) { + var _resolve; + // eslint-disable-next-line func-names + var promise = new Promise(function(resolve) { + token.subscribe(resolve); + _resolve = resolve; + }).then(onfulfilled); + + promise.cancel = function reject() { + token.unsubscribe(_resolve); + }; + + return promise; + }; + executor(function cancel(message) { if (token.reason) { // Cancellation has already been requested return; } - token.reason = new Cancel(message); + token.reason = new CanceledError(message); resolvePromise(token.reason); }); } /** - * Throws a `Cancel` if cancellation has been requested. + * Throws a `CanceledError` if cancellation has been requested. */ CancelToken.prototype.throwIfRequested = function throwIfRequested() { if (this.reason) { @@ -15932,6 +16549,37 @@ CancelToken.prototype.throwIfRequested = function throwIfRequested() { } }; +/** + * Subscribe to the cancel signal + */ + +CancelToken.prototype.subscribe = function subscribe(listener) { + if (this.reason) { + listener(this.reason); + return; + } + + if (this._listeners) { + this._listeners.push(listener); + } else { + this._listeners = [listener]; + } +}; + +/** + * Unsubscribe from the cancel signal + */ + +CancelToken.prototype.unsubscribe = function unsubscribe(listener) { + if (!this._listeners) { + return; + } + var index = this._listeners.indexOf(listener); + if (index !== -1) { + this._listeners.splice(index, 1); + } +}; + /** * Returns an object that contains a new `CancelToken` and a function that, when called, * cancels the `CancelToken`. @@ -15950,6 +16598,36 @@ CancelToken.source = function source() { module.exports = CancelToken; +/***/ }), + +/***/ "../../node_modules/axios/lib/cancel/CanceledError.js": +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +var AxiosError = __webpack_require__("../../node_modules/axios/lib/core/AxiosError.js"); +var utils = __webpack_require__("../../node_modules/axios/lib/utils.js"); + +/** + * A `CanceledError` is an object that is thrown when an operation is canceled. + * + * @class + * @param {string=} message The message. + */ +function CanceledError(message) { + // eslint-disable-next-line no-eq-null,eqeqeq + AxiosError.call(this, message == null ? 'canceled' : message, AxiosError.ERR_CANCELED); + this.name = 'CanceledError'; +} + +utils.inherits(CanceledError, AxiosError, { + __CANCEL__: true +}); + +module.exports = CanceledError; + + /***/ }), /***/ "../../node_modules/axios/lib/cancel/isCancel.js": @@ -15976,7 +16654,10 @@ var buildURL = __webpack_require__("../../node_modules/axios/lib/helpers/buildUR var InterceptorManager = __webpack_require__("../../node_modules/axios/lib/core/InterceptorManager.js"); var dispatchRequest = __webpack_require__("../../node_modules/axios/lib/core/dispatchRequest.js"); var mergeConfig = __webpack_require__("../../node_modules/axios/lib/core/mergeConfig.js"); +var buildFullPath = __webpack_require__("../../node_modules/axios/lib/core/buildFullPath.js"); +var validator = __webpack_require__("../../node_modules/axios/lib/helpers/validator.js"); +var validators = validator.validators; /** * Create a new instance of Axios * @@ -15995,14 +16676,14 @@ function Axios(instanceConfig) { * * @param {Object} config The config specific for this request (merged with this.defaults) */ -Axios.prototype.request = function request(config) { +Axios.prototype.request = function request(configOrUrl, config) { /*eslint no-param-reassign:0*/ // Allow for axios('example/url'[, config]) a la fetch API - if (typeof config === 'string') { - config = arguments[1] || {}; - config.url = arguments[0]; - } else { + if (typeof configOrUrl === 'string') { config = config || {}; + config.url = configOrUrl; + } else { + config = configOrUrl || {}; } config = mergeConfig(this.defaults, config); @@ -16016,20 +16697,71 @@ Axios.prototype.request = function request(config) { config.method = 'get'; } - // Hook up interceptors middleware - var chain = [dispatchRequest, undefined]; - var promise = Promise.resolve(config); + var transitional = config.transitional; + + if (transitional !== undefined) { + validator.assertOptions(transitional, { + silentJSONParsing: validators.transitional(validators.boolean), + forcedJSONParsing: validators.transitional(validators.boolean), + clarifyTimeoutError: validators.transitional(validators.boolean) + }, false); + } + // filter out skipped interceptors + var requestInterceptorChain = []; + var synchronousRequestInterceptors = true; this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) { - chain.unshift(interceptor.fulfilled, interceptor.rejected); + if (typeof interceptor.runWhen === 'function' && interceptor.runWhen(config) === false) { + return; + } + + synchronousRequestInterceptors = synchronousRequestInterceptors && interceptor.synchronous; + + requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected); }); + var responseInterceptorChain = []; this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) { - chain.push(interceptor.fulfilled, interceptor.rejected); + responseInterceptorChain.push(interceptor.fulfilled, interceptor.rejected); }); - while (chain.length) { - promise = promise.then(chain.shift(), chain.shift()); + var promise; + + if (!synchronousRequestInterceptors) { + var chain = [dispatchRequest, undefined]; + + Array.prototype.unshift.apply(chain, requestInterceptorChain); + chain = chain.concat(responseInterceptorChain); + + promise = Promise.resolve(config); + while (chain.length) { + promise = promise.then(chain.shift(), chain.shift()); + } + + return promise; + } + + + var newConfig = config; + while (requestInterceptorChain.length) { + var onFulfilled = requestInterceptorChain.shift(); + var onRejected = requestInterceptorChain.shift(); + try { + newConfig = onFulfilled(newConfig); + } catch (error) { + onRejected(error); + break; + } + } + + try { + promise = dispatchRequest(newConfig); + } catch (error) { + return Promise.reject(error); + } + + while (responseInterceptorChain.length) { + promise = promise.then(responseInterceptorChain.shift(), responseInterceptorChain.shift()); } return promise; @@ -16037,7 +16769,8 @@ Axios.prototype.request = function request(config) { Axios.prototype.getUri = function getUri(config) { config = mergeConfig(this.defaults, config); - return buildURL(config.url, config.params, config.paramsSerializer).replace(/^\?/, ''); + var fullPath = buildFullPath(config.baseURL, config.url); + return buildURL(fullPath, config.params, config.paramsSerializer); }; // Provide aliases for supported request methods @@ -16054,18 +16787,122 @@ utils.forEach(['delete', 'get', 'head', 'options'], function forEachMethodNoData utils.forEach(['post', 'put', 'patch'], function forEachMethodWithData(method) { /*eslint func-names:0*/ - Axios.prototype[method] = function(url, data, config) { - return this.request(mergeConfig(config || {}, { - method: method, - url: url, - data: data - })); - }; + + function generateHTTPMethod(isForm) { + return function httpMethod(url, data, config) { + return this.request(mergeConfig(config || {}, { + method: method, + headers: isForm ? { + 'Content-Type': 'multipart/form-data' + } : {}, + url: url, + data: data + })); + }; + } + + Axios.prototype[method] = generateHTTPMethod(); + + Axios.prototype[method + 'Form'] = generateHTTPMethod(true); }); module.exports = Axios; +/***/ }), + +/***/ "../../node_modules/axios/lib/core/AxiosError.js": +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +var utils = __webpack_require__("../../node_modules/axios/lib/utils.js"); + +/** + * Create an Error with the specified message, config, error code, request and response. + * + * @param {string} message The error message. + * @param {string} [code] The error code (for example, 'ECONNABORTED'). + * @param {Object} [config] The config. + * @param {Object} [request] The request. + * @param {Object} [response] The response. + * @returns {Error} The created error. + */ +function AxiosError(message, code, config, request, response) { + Error.call(this); + this.message = message; + this.name = 'AxiosError'; + code && (this.code = code); + config && (this.config = config); + request && (this.request = request); + response && (this.response = response); +} + +utils.inherits(AxiosError, Error, { + toJSON: function toJSON() { + return { + // Standard + message: this.message, + name: this.name, + // Microsoft + description: this.description, + number: this.number, + // Mozilla + fileName: this.fileName, + lineNumber: this.lineNumber, + columnNumber: this.columnNumber, + stack: this.stack, + // Axios + config: this.config, + code: this.code, + status: this.response && this.response.status ? this.response.status : null + }; + } +}); + +var prototype = AxiosError.prototype; +var descriptors = {}; + +[ + 'ERR_BAD_OPTION_VALUE', + 'ERR_BAD_OPTION', + 'ECONNABORTED', + 'ETIMEDOUT', + 'ERR_NETWORK', + 'ERR_FR_TOO_MANY_REDIRECTS', + 'ERR_DEPRECATED', + 'ERR_BAD_RESPONSE', + 'ERR_BAD_REQUEST', + 'ERR_CANCELED' +// eslint-disable-next-line func-names +].forEach(function(code) { + descriptors[code] = {value: code}; +}); + +Object.defineProperties(AxiosError, descriptors); +Object.defineProperty(prototype, 'isAxiosError', {value: true}); + +// eslint-disable-next-line func-names +AxiosError.from = function(error, code, config, request, response, customProps) { + var axiosError = Object.create(prototype); + + utils.toFlatObject(error, axiosError, function filter(obj) { + return obj !== Error.prototype; + }); + + AxiosError.call(axiosError, error.message, code, config, request, response); + + axiosError.name = error.name; + + customProps && Object.assign(axiosError, customProps); + + return axiosError; +}; + +module.exports = AxiosError; + + /***/ }), /***/ "../../node_modules/axios/lib/core/InterceptorManager.js": @@ -16088,10 +16925,12 @@ function InterceptorManager() { * * @return {Number} An ID used to remove interceptor later */ -InterceptorManager.prototype.use = function use(fulfilled, rejected) { +InterceptorManager.prototype.use = function use(fulfilled, rejected, options) { this.handlers.push({ fulfilled: fulfilled, - rejected: rejected + rejected: rejected, + synchronous: options ? options.synchronous : false, + runWhen: options ? options.runWhen : null }); return this.handlers.length - 1; }; @@ -16154,32 +16993,6 @@ module.exports = function buildFullPath(baseURL, requestedURL) { }; -/***/ }), - -/***/ "../../node_modules/axios/lib/core/createError.js": -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -var enhanceError = __webpack_require__("../../node_modules/axios/lib/core/enhanceError.js"); - -/** - * Create an Error with the specified message, config, error code, request and response. - * - * @param {string} message The error message. - * @param {Object} config The config. - * @param {string} [code] The error code (for example, 'ECONNABORTED'). - * @param {Object} [request] The request. - * @param {Object} [response] The response. - * @returns {Error} The created error. - */ -module.exports = function createError(message, config, code, request, response) { - var error = new Error(message); - return enhanceError(error, config, code, request, response); -}; - - /***/ }), /***/ "../../node_modules/axios/lib/core/dispatchRequest.js": @@ -16191,15 +17004,20 @@ module.exports = function createError(message, config, code, request, response) var utils = __webpack_require__("../../node_modules/axios/lib/utils.js"); var transformData = __webpack_require__("../../node_modules/axios/lib/core/transformData.js"); var isCancel = __webpack_require__("../../node_modules/axios/lib/cancel/isCancel.js"); -var defaults = __webpack_require__("../../node_modules/axios/lib/defaults.js"); +var defaults = __webpack_require__("../../node_modules/axios/lib/defaults/index.js"); +var CanceledError = __webpack_require__("../../node_modules/axios/lib/cancel/CanceledError.js"); /** - * Throws a `Cancel` if cancellation has been requested. + * Throws a `CanceledError` if cancellation has been requested. */ function throwIfCancellationRequested(config) { if (config.cancelToken) { config.cancelToken.throwIfRequested(); } + + if (config.signal && config.signal.aborted) { + throw new CanceledError(); + } } /** @@ -16215,7 +17033,8 @@ module.exports = function dispatchRequest(config) { config.headers = config.headers || {}; // Transform request data - config.data = transformData( + config.data = transformData.call( + config, config.data, config.headers, config.transformRequest @@ -16241,7 +17060,8 @@ module.exports = function dispatchRequest(config) { throwIfCancellationRequested(config); // Transform response data - response.data = transformData( + response.data = transformData.call( + config, response.data, response.headers, config.transformResponse @@ -16254,7 +17074,8 @@ module.exports = function dispatchRequest(config) { // Transform response data if (reason && reason.response) { - reason.response.data = transformData( + reason.response.data = transformData.call( + config, reason.response.data, reason.response.headers, config.transformResponse @@ -16267,56 +17088,6 @@ module.exports = function dispatchRequest(config) { }; -/***/ }), - -/***/ "../../node_modules/axios/lib/core/enhanceError.js": -/***/ (function(module, exports, __webpack_require__) { - -"use strict"; - - -/** - * Update an Error with the specified config, error code, and response. - * - * @param {Error} error The error to update. - * @param {Object} config The config. - * @param {string} [code] The error code (for example, 'ECONNABORTED'). - * @param {Object} [request] The request. - * @param {Object} [response] The response. - * @returns {Error} The error. - */ -module.exports = function enhanceError(error, config, code, request, response) { - error.config = config; - if (code) { - error.code = code; - } - - error.request = request; - error.response = response; - error.isAxiosError = true; - - error.toJSON = function toJSON() { - return { - // Standard - message: this.message, - name: this.name, - // Microsoft - description: this.description, - number: this.number, - // Mozilla - fileName: this.fileName, - lineNumber: this.lineNumber, - columnNumber: this.columnNumber, - stack: this.stack, - // Axios - config: this.config, - code: this.code - }; - }; - return error; -}; - - /***/ }), /***/ "../../node_modules/axios/lib/core/mergeConfig.js": @@ -16340,17 +17111,6 @@ module.exports = function mergeConfig(config1, config2) { config2 = config2 || {}; var config = {}; - var valueFromConfig2Keys = ['url', 'method', 'data']; - var mergeDeepPropertiesKeys = ['headers', 'auth', 'proxy', 'params']; - var defaultToConfig2Keys = [ - 'baseURL', 'transformRequest', 'transformResponse', 'paramsSerializer', - 'timeout', 'timeoutMessage', 'withCredentials', 'adapter', 'responseType', 'xsrfCookieName', - 'xsrfHeaderName', 'onUploadProgress', 'onDownloadProgress', 'decompress', - 'maxContentLength', 'maxBodyLength', 'maxRedirects', 'transport', 'httpAgent', - 'httpsAgent', 'cancelToken', 'socketPath', 'responseEncoding' - ]; - var directMergeKeys = ['validateStatus']; - function getMergedValue(target, source) { if (utils.isPlainObject(target) && utils.isPlainObject(source)) { return utils.merge(target, source); @@ -16362,51 +17122,75 @@ module.exports = function mergeConfig(config1, config2) { return source; } + // eslint-disable-next-line consistent-return function mergeDeepProperties(prop) { if (!utils.isUndefined(config2[prop])) { - config[prop] = getMergedValue(config1[prop], config2[prop]); + return getMergedValue(config1[prop], config2[prop]); } else if (!utils.isUndefined(config1[prop])) { - config[prop] = getMergedValue(undefined, config1[prop]); + return getMergedValue(undefined, config1[prop]); } } - utils.forEach(valueFromConfig2Keys, function valueFromConfig2(prop) { + // eslint-disable-next-line consistent-return + function valueFromConfig2(prop) { if (!utils.isUndefined(config2[prop])) { - config[prop] = getMergedValue(undefined, config2[prop]); + return getMergedValue(undefined, config2[prop]); } - }); - - utils.forEach(mergeDeepPropertiesKeys, mergeDeepProperties); + } - utils.forEach(defaultToConfig2Keys, function defaultToConfig2(prop) { + // eslint-disable-next-line consistent-return + function defaultToConfig2(prop) { if (!utils.isUndefined(config2[prop])) { - config[prop] = getMergedValue(undefined, config2[prop]); + return getMergedValue(undefined, config2[prop]); } else if (!utils.isUndefined(config1[prop])) { - config[prop] = getMergedValue(undefined, config1[prop]); + return getMergedValue(undefined, config1[prop]); } - }); + } - utils.forEach(directMergeKeys, function merge(prop) { + // eslint-disable-next-line consistent-return + function mergeDirectKeys(prop) { if (prop in config2) { - config[prop] = getMergedValue(config1[prop], config2[prop]); + return getMergedValue(config1[prop], config2[prop]); } else if (prop in config1) { - config[prop] = getMergedValue(undefined, config1[prop]); - } - }); - - var axiosKeys = valueFromConfig2Keys - .concat(mergeDeepPropertiesKeys) - .concat(defaultToConfig2Keys) - .concat(directMergeKeys); - - var otherKeys = Object - .keys(config1) - .concat(Object.keys(config2)) - .filter(function filterAxiosKeys(key) { - return axiosKeys.indexOf(key) === -1; - }); + return getMergedValue(undefined, config1[prop]); + } + } + + var mergeMap = { + 'url': valueFromConfig2, + 'method': valueFromConfig2, + 'data': valueFromConfig2, + 'baseURL': defaultToConfig2, + 'transformRequest': defaultToConfig2, + 'transformResponse': defaultToConfig2, + 'paramsSerializer': defaultToConfig2, + 'timeout': defaultToConfig2, + 'timeoutMessage': defaultToConfig2, + 'withCredentials': defaultToConfig2, + 'adapter': defaultToConfig2, + 'responseType': defaultToConfig2, + 'xsrfCookieName': defaultToConfig2, + 'xsrfHeaderName': defaultToConfig2, + 'onUploadProgress': defaultToConfig2, + 'onDownloadProgress': defaultToConfig2, + 'decompress': defaultToConfig2, + 'maxContentLength': defaultToConfig2, + 'maxBodyLength': defaultToConfig2, + 'beforeRedirect': defaultToConfig2, + 'transport': defaultToConfig2, + 'httpAgent': defaultToConfig2, + 'httpsAgent': defaultToConfig2, + 'cancelToken': defaultToConfig2, + 'socketPath': defaultToConfig2, + 'responseEncoding': defaultToConfig2, + 'validateStatus': mergeDirectKeys + }; - utils.forEach(otherKeys, mergeDeepProperties); + utils.forEach(Object.keys(config1).concat(Object.keys(config2)), function computeConfigValue(prop) { + var merge = mergeMap[prop] || mergeDeepProperties; + var configValue = merge(prop); + (utils.isUndefined(configValue) && merge !== mergeDirectKeys) || (config[prop] = configValue); + }); return config; }; @@ -16420,7 +17204,7 @@ module.exports = function mergeConfig(config1, config2) { "use strict"; -var createError = __webpack_require__("../../node_modules/axios/lib/core/createError.js"); +var AxiosError = __webpack_require__("../../node_modules/axios/lib/core/AxiosError.js"); /** * Resolve or reject a Promise based on response status. @@ -16434,10 +17218,10 @@ module.exports = function settle(resolve, reject, response) { if (!response.status || !validateStatus || validateStatus(response.status)) { resolve(response); } else { - reject(createError( + reject(new AxiosError( 'Request failed with status code ' + response.status, + [AxiosError.ERR_BAD_REQUEST, AxiosError.ERR_BAD_RESPONSE][Math.floor(response.status / 100) - 4], response.config, - null, response.request, response )); @@ -16454,6 +17238,7 @@ module.exports = function settle(resolve, reject, response) { var utils = __webpack_require__("../../node_modules/axios/lib/utils.js"); +var defaults = __webpack_require__("../../node_modules/axios/lib/defaults/index.js"); /** * Transform the data for a request or a response @@ -16464,9 +17249,10 @@ var utils = __webpack_require__("../../node_modules/axios/lib/utils.js"); * @returns {*} The resulting transformed data */ module.exports = function transformData(data, headers, fns) { + var context = this || defaults; /*eslint no-param-reassign:0*/ utils.forEach(fns, function transform(fn) { - data = fn(data, headers); + data = fn.call(context, data, headers); }); return data; @@ -16475,7 +17261,16 @@ module.exports = function transformData(data, headers, fns) { /***/ }), -/***/ "../../node_modules/axios/lib/defaults.js": +/***/ "../../node_modules/axios/lib/defaults/env/FormData.js": +/***/ (function(module, exports, __webpack_require__) { + +// eslint-disable-next-line strict +module.exports = __webpack_require__("../../node_modules/form-data/lib/form_data.js"); + + +/***/ }), + +/***/ "../../node_modules/axios/lib/defaults/index.js": /***/ (function(module, exports, __webpack_require__) { "use strict"; @@ -16483,6 +17278,9 @@ module.exports = function transformData(data, headers, fns) { var utils = __webpack_require__("../../node_modules/axios/lib/utils.js"); var normalizeHeaderName = __webpack_require__("../../node_modules/axios/lib/helpers/normalizeHeaderName.js"); +var AxiosError = __webpack_require__("../../node_modules/axios/lib/core/AxiosError.js"); +var transitionalDefaults = __webpack_require__("../../node_modules/axios/lib/defaults/transitional.js"); +var toFormData = __webpack_require__("../../node_modules/axios/lib/helpers/toFormData.js"); var DEFAULT_CONTENT_TYPE = { 'Content-Type': 'application/x-www-form-urlencoded' @@ -16506,12 +17304,31 @@ function getDefaultAdapter() { return adapter; } +function stringifySafely(rawValue, parser, encoder) { + if (utils.isString(rawValue)) { + try { + (parser || JSON.parse)(rawValue); + return utils.trim(rawValue); + } catch (e) { + if (e.name !== 'SyntaxError') { + throw e; + } + } + } + + return (encoder || JSON.stringify)(rawValue); +} + var defaults = { + + transitional: transitionalDefaults, + adapter: getDefaultAdapter(), transformRequest: [function transformRequest(data, headers) { normalizeHeaderName(headers, 'Accept'); normalizeHeaderName(headers, 'Content-Type'); + if (utils.isFormData(data) || utils.isArrayBuffer(data) || utils.isBuffer(data) || @@ -16528,20 +17345,42 @@ var defaults = { setContentTypeIfUnset(headers, 'application/x-www-form-urlencoded;charset=utf-8'); return data.toString(); } - if (utils.isObject(data)) { - setContentTypeIfUnset(headers, 'application/json;charset=utf-8'); - return JSON.stringify(data); + + var isObjectPayload = utils.isObject(data); + var contentType = headers && headers['Content-Type']; + + var isFileList; + + if ((isFileList = utils.isFileList(data)) || (isObjectPayload && contentType === 'multipart/form-data')) { + var _FormData = this.env && this.env.FormData; + return toFormData(isFileList ? {'files[]': data} : data, _FormData && new _FormData()); + } else if (isObjectPayload || contentType === 'application/json') { + setContentTypeIfUnset(headers, 'application/json'); + return stringifySafely(data); } + return data; }], transformResponse: [function transformResponse(data) { - /*eslint no-param-reassign:0*/ - if (typeof data === 'string') { + var transitional = this.transitional || defaults.transitional; + var silentJSONParsing = transitional && transitional.silentJSONParsing; + var forcedJSONParsing = transitional && transitional.forcedJSONParsing; + var strictJSONParsing = !silentJSONParsing && this.responseType === 'json'; + + if (strictJSONParsing || (forcedJSONParsing && utils.isString(data) && data.length)) { try { - data = JSON.parse(data); - } catch (e) { /* Ignore */ } + return JSON.parse(data); + } catch (e) { + if (strictJSONParsing) { + if (e.name === 'SyntaxError') { + throw AxiosError.from(e, AxiosError.ERR_BAD_RESPONSE, this, null, this.response); + } + throw e; + } + } } + return data; }], @@ -16557,14 +17396,18 @@ var defaults = { maxContentLength: -1, maxBodyLength: -1, + env: { + FormData: __webpack_require__("../../node_modules/axios/lib/defaults/env/FormData.js") + }, + validateStatus: function validateStatus(status) { return status >= 200 && status < 300; - } -}; + }, -defaults.headers = { - common: { - 'Accept': 'application/json, text/plain, */*' + headers: { + common: { + 'Accept': 'application/json, text/plain, */*' + } } }; @@ -16579,6 +17422,30 @@ utils.forEach(['post', 'put', 'patch'], function forEachMethodWithData(method) { module.exports = defaults; +/***/ }), + +/***/ "../../node_modules/axios/lib/defaults/transitional.js": +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +module.exports = { + silentJSONParsing: true, + forcedJSONParsing: true, + clarifyTimeoutError: false +}; + + +/***/ }), + +/***/ "../../node_modules/axios/lib/env/data.js": +/***/ (function(module, exports) { + +module.exports = { + "version": "0.27.2" +}; + /***/ }), /***/ "../../node_modules/axios/lib/helpers/bind.js": @@ -16777,7 +17644,7 @@ module.exports = function isAbsoluteURL(url) { // A URL is considered absolute if it begins with "://" or "//" (protocol-relative URL). // RFC 3986 defines scheme name as a sequence of characters beginning with a letter and followed // by any combination of letters, digits, plus, period, or hyphen. - return /^([a-z][a-z\d\+\-\.]*:)?\/\//i.test(url); + return /^([a-z][a-z\d+\-.]*:)?\/\//i.test(url); }; @@ -16789,6 +17656,8 @@ module.exports = function isAbsoluteURL(url) { "use strict"; +var utils = __webpack_require__("../../node_modules/axios/lib/utils.js"); + /** * Determines whether the payload is an error thrown by Axios * @@ -16796,7 +17665,7 @@ module.exports = function isAbsoluteURL(url) { * @returns {boolean} True if the payload is an error thrown by Axios, otherwise false */ module.exports = function isAxiosError(payload) { - return (typeof payload === 'object') && (payload.isAxiosError === true); + return utils.isObject(payload) && (payload.isAxiosError === true); }; @@ -16957,6 +17826,20 @@ module.exports = function parseHeaders(headers) { }; +/***/ }), + +/***/ "../../node_modules/axios/lib/helpers/parseProtocol.js": +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +module.exports = function parseProtocol(url) { + var match = /^([-+\w]{1,25})(:?\/\/|:)/.exec(url); + return match && match[1] || ''; +}; + + /***/ }), /***/ "../../node_modules/axios/lib/helpers/spread.js": @@ -16992,6 +17875,180 @@ module.exports = function spread(callback) { }; +/***/ }), + +/***/ "../../node_modules/axios/lib/helpers/toFormData.js": +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +var utils = __webpack_require__("../../node_modules/axios/lib/utils.js"); + +/** + * Convert a data object to FormData + * @param {Object} obj + * @param {?Object} [formData] + * @returns {Object} + **/ + +function toFormData(obj, formData) { + // eslint-disable-next-line no-param-reassign + formData = formData || new FormData(); + + var stack = []; + + function convertValue(value) { + if (value === null) return ''; + + if (utils.isDate(value)) { + return value.toISOString(); + } + + if (utils.isArrayBuffer(value) || utils.isTypedArray(value)) { + return typeof Blob === 'function' ? new Blob([value]) : Buffer.from(value); + } + + return value; + } + + function build(data, parentKey) { + if (utils.isPlainObject(data) || utils.isArray(data)) { + if (stack.indexOf(data) !== -1) { + throw Error('Circular reference detected in ' + parentKey); + } + + stack.push(data); + + utils.forEach(data, function each(value, key) { + if (utils.isUndefined(value)) return; + var fullKey = parentKey ? parentKey + '.' + key : key; + var arr; + + if (value && !parentKey && typeof value === 'object') { + if (utils.endsWith(key, '{}')) { + // eslint-disable-next-line no-param-reassign + value = JSON.stringify(value); + } else if (utils.endsWith(key, '[]') && (arr = utils.toArray(value))) { + // eslint-disable-next-line func-names + arr.forEach(function(el) { + !utils.isUndefined(el) && formData.append(fullKey, convertValue(el)); + }); + return; + } + } + + build(value, fullKey); + }); + + stack.pop(); + } else { + formData.append(parentKey, convertValue(data)); + } + } + + build(obj); + + return formData; +} + +module.exports = toFormData; + + +/***/ }), + +/***/ "../../node_modules/axios/lib/helpers/validator.js": +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; + + +var VERSION = __webpack_require__("../../node_modules/axios/lib/env/data.js").version; +var AxiosError = __webpack_require__("../../node_modules/axios/lib/core/AxiosError.js"); + +var validators = {}; + +// eslint-disable-next-line func-names +['object', 'boolean', 'number', 'function', 'string', 'symbol'].forEach(function(type, i) { + validators[type] = function validator(thing) { + return typeof thing === type || 'a' + (i < 1 ? 'n ' : ' ') + type; + }; +}); + +var deprecatedWarnings = {}; + +/** + * Transitional option validator + * @param {function|boolean?} validator - set to false if the transitional option has been removed + * @param {string?} version - deprecated version / removed since version + * @param {string?} message - some message with additional info + * @returns {function} + */ +validators.transitional = function transitional(validator, version, message) { + function formatMessage(opt, desc) { + return '[Axios v' + VERSION + '] Transitional option \'' + opt + '\'' + desc + (message ? '. ' + message : ''); + } + + // eslint-disable-next-line func-names + return function(value, opt, opts) { + if (validator === false) { + throw new AxiosError( + formatMessage(opt, ' has been removed' + (version ? ' in ' + version : '')), + AxiosError.ERR_DEPRECATED + ); + } + + if (version && !deprecatedWarnings[opt]) { + deprecatedWarnings[opt] = true; + // eslint-disable-next-line no-console + console.warn( + formatMessage( + opt, + ' has been deprecated since v' + version + ' and will be removed in the near future' + ) + ); + } + + return validator ? validator(value, opt, opts) : true; + }; +}; + +/** + * Assert object's properties type + * @param {object} options + * @param {object} schema + * @param {boolean?} allowUnknown + */ + +function assertOptions(options, schema, allowUnknown) { + if (typeof options !== 'object') { + throw new AxiosError('options must be an object', AxiosError.ERR_BAD_OPTION_VALUE); + } + var keys = Object.keys(options); + var i = keys.length; + while (i-- > 0) { + var opt = keys[i]; + var validator = schema[opt]; + if (validator) { + var value = options[opt]; + var result = value === undefined || validator(value, opt, options); + if (result !== true) { + throw new AxiosError('option ' + opt + ' must be ' + result, AxiosError.ERR_BAD_OPTION_VALUE); + } + continue; + } + if (allowUnknown !== true) { + throw new AxiosError('Unknown option ' + opt, AxiosError.ERR_BAD_OPTION); + } + } +} + +module.exports = { + assertOptions: assertOptions, + validators: validators +}; + + /***/ }), /***/ "../../node_modules/axios/lib/utils.js": @@ -17002,12 +18059,26 @@ module.exports = function spread(callback) { var bind = __webpack_require__("../../node_modules/axios/lib/helpers/bind.js"); -/*global toString:true*/ - // utils is a library of generic helper functions non-specific to axios var toString = Object.prototype.toString; +// eslint-disable-next-line func-names +var kindOf = (function(cache) { + // eslint-disable-next-line func-names + return function(thing) { + var str = toString.call(thing); + return cache[str] || (cache[str] = str.slice(8, -1).toLowerCase()); + }; +})(Object.create(null)); + +function kindOfTest(type) { + type = type.toLowerCase(); + return function isKindOf(thing) { + return kindOf(thing) === type; + }; +} + /** * Determine if a value is an Array * @@ -17015,7 +18086,7 @@ var toString = Object.prototype.toString; * @returns {boolean} True if value is an Array, otherwise false */ function isArray(val) { - return toString.call(val) === '[object Array]'; + return Array.isArray(val); } /** @@ -17042,22 +18113,12 @@ function isBuffer(val) { /** * Determine if a value is an ArrayBuffer * + * @function * @param {Object} val The value to test * @returns {boolean} True if value is an ArrayBuffer, otherwise false */ -function isArrayBuffer(val) { - return toString.call(val) === '[object ArrayBuffer]'; -} +var isArrayBuffer = kindOfTest('ArrayBuffer'); -/** - * Determine if a value is a FormData - * - * @param {Object} val The value to test - * @returns {boolean} True if value is an FormData, otherwise false - */ -function isFormData(val) { - return (typeof FormData !== 'undefined') && (val instanceof FormData); -} /** * Determine if a value is a view on an ArrayBuffer @@ -17070,7 +18131,7 @@ function isArrayBufferView(val) { if ((typeof ArrayBuffer !== 'undefined') && (ArrayBuffer.isView)) { result = ArrayBuffer.isView(val); } else { - result = (val) && (val.buffer) && (val.buffer instanceof ArrayBuffer); + result = (val) && (val.buffer) && (isArrayBuffer(val.buffer)); } return result; } @@ -17112,7 +18173,7 @@ function isObject(val) { * @return {boolean} True if value is a plain Object, otherwise false */ function isPlainObject(val) { - if (toString.call(val) !== '[object Object]') { + if (kindOf(val) !== 'object') { return false; } @@ -17123,32 +18184,38 @@ function isPlainObject(val) { /** * Determine if a value is a Date * + * @function * @param {Object} val The value to test * @returns {boolean} True if value is a Date, otherwise false */ -function isDate(val) { - return toString.call(val) === '[object Date]'; -} +var isDate = kindOfTest('Date'); /** * Determine if a value is a File * + * @function * @param {Object} val The value to test * @returns {boolean} True if value is a File, otherwise false */ -function isFile(val) { - return toString.call(val) === '[object File]'; -} +var isFile = kindOfTest('File'); /** * Determine if a value is a Blob * + * @function * @param {Object} val The value to test * @returns {boolean} True if value is a Blob, otherwise false */ -function isBlob(val) { - return toString.call(val) === '[object Blob]'; -} +var isBlob = kindOfTest('Blob'); + +/** + * Determine if a value is a FileList + * + * @function + * @param {Object} val The value to test + * @returns {boolean} True if value is a File, otherwise false + */ +var isFileList = kindOfTest('FileList'); /** * Determine if a value is a Function @@ -17171,14 +18238,27 @@ function isStream(val) { } /** - * Determine if a value is a URLSearchParams object + * Determine if a value is a FormData * + * @param {Object} thing The value to test + * @returns {boolean} True if value is an FormData, otherwise false + */ +function isFormData(thing) { + var pattern = '[object FormData]'; + return thing && ( + (typeof FormData === 'function' && thing instanceof FormData) || + toString.call(thing) === pattern || + (isFunction(thing.toString) && thing.toString() === pattern) + ); +} + +/** + * Determine if a value is a URLSearchParams object + * @function * @param {Object} val The value to test * @returns {boolean} True if value is a URLSearchParams object, otherwise false */ -function isURLSearchParams(val) { - return typeof URLSearchParams !== 'undefined' && val instanceof URLSearchParams; -} +var isURLSearchParams = kindOfTest('URLSearchParams'); /** * Trim excess whitespace off the beginning and end of a string @@ -17187,7 +18267,7 @@ function isURLSearchParams(val) { * @returns {String} The String freed of excess whitespace */ function trim(str) { - return str.replace(/^\s*/, '').replace(/\s*$/, ''); + return str.trim ? str.trim() : str.replace(/^\s+|\s+$/g, ''); } /** @@ -17325,6 +18405,94 @@ function stripBOM(content) { return content; } +/** + * Inherit the prototype methods from one constructor into another + * @param {function} constructor + * @param {function} superConstructor + * @param {object} [props] + * @param {object} [descriptors] + */ + +function inherits(constructor, superConstructor, props, descriptors) { + constructor.prototype = Object.create(superConstructor.prototype, descriptors); + constructor.prototype.constructor = constructor; + props && Object.assign(constructor.prototype, props); +} + +/** + * Resolve object with deep prototype chain to a flat object + * @param {Object} sourceObj source object + * @param {Object} [destObj] + * @param {Function} [filter] + * @returns {Object} + */ + +function toFlatObject(sourceObj, destObj, filter) { + var props; + var i; + var prop; + var merged = {}; + + destObj = destObj || {}; + + do { + props = Object.getOwnPropertyNames(sourceObj); + i = props.length; + while (i-- > 0) { + prop = props[i]; + if (!merged[prop]) { + destObj[prop] = sourceObj[prop]; + merged[prop] = true; + } + } + sourceObj = Object.getPrototypeOf(sourceObj); + } while (sourceObj && (!filter || filter(sourceObj, destObj)) && sourceObj !== Object.prototype); + + return destObj; +} + +/* + * determines whether a string ends with the characters of a specified string + * @param {String} str + * @param {String} searchString + * @param {Number} [position= 0] + * @returns {boolean} + */ +function endsWith(str, searchString, position) { + str = String(str); + if (position === undefined || position > str.length) { + position = str.length; + } + position -= searchString.length; + var lastIndex = str.indexOf(searchString, position); + return lastIndex !== -1 && lastIndex === position; +} + + +/** + * Returns new array from array like object + * @param {*} [thing] + * @returns {Array} + */ +function toArray(thing) { + if (!thing) return null; + var i = thing.length; + if (isUndefined(i)) return null; + var arr = new Array(i); + while (i-- > 0) { + arr[i] = thing[i]; + } + return arr; +} + +// eslint-disable-next-line func-names +var isTypedArray = (function(TypedArray) { + // eslint-disable-next-line func-names + return function(thing) { + return TypedArray && thing instanceof TypedArray; + }; +})(typeof Uint8Array !== 'undefined' && Object.getPrototypeOf(Uint8Array)); + module.exports = { isArray: isArray, isArrayBuffer: isArrayBuffer, @@ -17347,17 +18515,18 @@ module.exports = { merge: merge, extend: extend, trim: trim, - stripBOM: stripBOM + stripBOM: stripBOM, + inherits: inherits, + toFlatObject: toFlatObject, + kindOf: kindOf, + kindOfTest: kindOfTest, + endsWith: endsWith, + toArray: toArray, + isTypedArray: isTypedArray, + isFileList: isFileList }; -/***/ }), - -/***/ "../../node_modules/axios/package.json": -/***/ (function(module) { - -module.exports = JSON.parse("{\"name\":\"axios\",\"version\":\"0.21.1\",\"description\":\"Promise based HTTP client for the browser and node.js\",\"main\":\"index.js\",\"scripts\":{\"test\":\"grunt test && bundlesize\",\"start\":\"node ./sandbox/server.js\",\"build\":\"NODE_ENV=production grunt build\",\"preversion\":\"npm test\",\"version\":\"npm run build && grunt version && git add -A dist && git add CHANGELOG.md bower.json package.json\",\"postversion\":\"git push && git push --tags\",\"examples\":\"node ./examples/server.js\",\"coveralls\":\"cat coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js\",\"fix\":\"eslint --fix lib/**/*.js\"},\"repository\":{\"type\":\"git\",\"url\":\"https://github.com/axios/axios.git\"},\"keywords\":[\"xhr\",\"http\",\"ajax\",\"promise\",\"node\"],\"author\":\"Matt Zabriskie\",\"license\":\"MIT\",\"bugs\":{\"url\":\"https://github.com/axios/axios/issues\"},\"homepage\":\"https://github.com/axios/axios\",\"devDependencies\":{\"bundlesize\":\"^0.17.0\",\"coveralls\":\"^3.0.0\",\"es6-promise\":\"^4.2.4\",\"grunt\":\"^1.0.2\",\"grunt-banner\":\"^0.6.0\",\"grunt-cli\":\"^1.2.0\",\"grunt-contrib-clean\":\"^1.1.0\",\"grunt-contrib-watch\":\"^1.0.0\",\"grunt-eslint\":\"^20.1.0\",\"grunt-karma\":\"^2.0.0\",\"grunt-mocha-test\":\"^0.13.3\",\"grunt-ts\":\"^6.0.0-beta.19\",\"grunt-webpack\":\"^1.0.18\",\"istanbul-instrumenter-loader\":\"^1.0.0\",\"jasmine-core\":\"^2.4.1\",\"karma\":\"^1.3.0\",\"karma-chrome-launcher\":\"^2.2.0\",\"karma-coverage\":\"^1.1.1\",\"karma-firefox-launcher\":\"^1.1.0\",\"karma-jasmine\":\"^1.1.1\",\"karma-jasmine-ajax\":\"^0.1.13\",\"karma-opera-launcher\":\"^1.0.0\",\"karma-safari-launcher\":\"^1.0.0\",\"karma-sauce-launcher\":\"^1.2.0\",\"karma-sinon\":\"^1.0.5\",\"karma-sourcemap-loader\":\"^0.3.7\",\"karma-webpack\":\"^1.7.0\",\"load-grunt-tasks\":\"^3.5.2\",\"minimist\":\"^1.2.0\",\"mocha\":\"^5.2.0\",\"sinon\":\"^4.5.0\",\"typescript\":\"^2.8.1\",\"url-search-params\":\"^0.10.0\",\"webpack\":\"^1.13.1\",\"webpack-dev-server\":\"^1.14.1\"},\"browser\":{\"./lib/adapters/http.js\":\"./lib/adapters/xhr.js\"},\"jsdelivr\":\"dist/axios.min.js\",\"unpkg\":\"dist/axios.min.js\",\"typings\":\"./index.d.ts\",\"dependencies\":{\"follow-redirects\":\"^1.10.0\"},\"bundlesize\":[{\"path\":\"./dist/axios.min.js\",\"threshold\":\"5kB\"}]}"); - /***/ }), /***/ "../../node_modules/balanced-match/index.js": @@ -19305,7 +20474,7 @@ cmdShim.ifExists = cmdShimIfExists var fs = __webpack_require__("../../node_modules/graceful-fs/graceful-fs.js") -var mkdir = __webpack_require__("../../node_modules/cmd-shim/node_modules/mkdirp/index.js") +var mkdir = __webpack_require__("../../node_modules/mkdirp/index.js") , path = __webpack_require__("path") , toBatchSyntax = __webpack_require__("../../node_modules/cmd-shim/lib/to-batch-syntax.js") , shebangExpr = /^#\!\s*(?:\/usr\/bin\/env)?\s*([^ \t]+=[^ \t]+\s+)*\s*([^ \t]+)(.*)$/ @@ -19598,112 +20767,6 @@ function replaceDollarWithPercentPair(value) { -/***/ }), - -/***/ "../../node_modules/cmd-shim/node_modules/mkdirp/index.js": -/***/ (function(module, exports, __webpack_require__) { - -var path = __webpack_require__("path"); -var fs = __webpack_require__("fs"); -var _0777 = parseInt('0777', 8); - -module.exports = mkdirP.mkdirp = mkdirP.mkdirP = mkdirP; - -function mkdirP (p, opts, f, made) { - if (typeof opts === 'function') { - f = opts; - opts = {}; - } - else if (!opts || typeof opts !== 'object') { - opts = { mode: opts }; - } - - var mode = opts.mode; - var xfs = opts.fs || fs; - - if (mode === undefined) { - mode = _0777 & (~process.umask()); - } - if (!made) made = null; - - var cb = f || function () {}; - p = path.resolve(p); - - xfs.mkdir(p, mode, function (er) { - if (!er) { - made = made || p; - return cb(null, made); - } - switch (er.code) { - case 'ENOENT': - if (path.dirname(p) === p) return cb(er); - mkdirP(path.dirname(p), opts, function (er, made) { - if (er) cb(er, made); - else mkdirP(p, opts, cb, made); - }); - break; - - // In the case of any other error, just see if there's a dir - // there already. If so, then hooray! If not, then something - // is borked. - default: - xfs.stat(p, function (er2, stat) { - // if the stat fails, then that's super weird. - // let the original error be the failure reason. - if (er2 || !stat.isDirectory()) cb(er, made) - else cb(null, made); - }); - break; - } - }); -} - -mkdirP.sync = function sync (p, opts, made) { - if (!opts || typeof opts !== 'object') { - opts = { mode: opts }; - } - - var mode = opts.mode; - var xfs = opts.fs || fs; - - if (mode === undefined) { - mode = _0777 & (~process.umask()); - } - if (!made) made = null; - - p = path.resolve(p); - - try { - xfs.mkdirSync(p, mode); - made = made || p; - } - catch (err0) { - switch (err0.code) { - case 'ENOENT' : - made = sync(path.dirname(p), opts, made); - sync(p, opts, made); - break; - - // In the case of any other error, just see if there's a dir - // there already. If so, then hooray! If not, then something - // is borked. - default: - var stat; - try { - stat = xfs.statSync(p); - } - catch (err1) { - throw err0; - } - if (!stat.isDirectory()) throw err0; - break; - } - } - - return made; -}; - - /***/ }), /***/ "../../node_modules/color-convert/conversions.js": @@ -21088,6 +22151,221 @@ module.exports = { }; +/***/ }), + +/***/ "../../node_modules/combined-stream/lib/combined_stream.js": +/***/ (function(module, exports, __webpack_require__) { + +var util = __webpack_require__("util"); +var Stream = __webpack_require__("stream").Stream; +var DelayedStream = __webpack_require__("../../node_modules/delayed-stream/lib/delayed_stream.js"); + +module.exports = CombinedStream; +function CombinedStream() { + this.writable = false; + this.readable = true; + this.dataSize = 0; + this.maxDataSize = 2 * 1024 * 1024; + this.pauseStreams = true; + + this._released = false; + this._streams = []; + this._currentStream = null; + this._insideLoop = false; + this._pendingNext = false; +} +util.inherits(CombinedStream, Stream); + +CombinedStream.create = function(options) { + var combinedStream = new this(); + + options = options || {}; + for (var option in options) { + combinedStream[option] = options[option]; + } + + return combinedStream; +}; + +CombinedStream.isStreamLike = function(stream) { + return (typeof stream !== 'function') + && (typeof stream !== 'string') + && (typeof stream !== 'boolean') + && (typeof stream !== 'number') + && (!Buffer.isBuffer(stream)); +}; + +CombinedStream.prototype.append = function(stream) { + var isStreamLike = CombinedStream.isStreamLike(stream); + + if (isStreamLike) { + if (!(stream instanceof DelayedStream)) { + var newStream = DelayedStream.create(stream, { + maxDataSize: Infinity, + pauseStream: this.pauseStreams, + }); + stream.on('data', this._checkDataSize.bind(this)); + stream = newStream; + } + + this._handleErrors(stream); + + if (this.pauseStreams) { + stream.pause(); + } + } + + this._streams.push(stream); + return this; +}; + +CombinedStream.prototype.pipe = function(dest, options) { + Stream.prototype.pipe.call(this, dest, options); + this.resume(); + return dest; +}; + +CombinedStream.prototype._getNext = function() { + this._currentStream = null; + + if (this._insideLoop) { + this._pendingNext = true; + return; // defer call + } + + this._insideLoop = true; + try { + do { + this._pendingNext = false; + this._realGetNext(); + } while (this._pendingNext); + } finally { + this._insideLoop = false; + } +}; + +CombinedStream.prototype._realGetNext = function() { + var stream = this._streams.shift(); + + + if (typeof stream == 'undefined') { + this.end(); + return; + } + + if (typeof stream !== 'function') { + this._pipeNext(stream); + return; + } + + var getStream = stream; + getStream(function(stream) { + var isStreamLike = CombinedStream.isStreamLike(stream); + if (isStreamLike) { + stream.on('data', this._checkDataSize.bind(this)); + this._handleErrors(stream); + } + + this._pipeNext(stream); + }.bind(this)); +}; + +CombinedStream.prototype._pipeNext = function(stream) { + this._currentStream = stream; + + var isStreamLike = CombinedStream.isStreamLike(stream); + if (isStreamLike) { + stream.on('end', this._getNext.bind(this)); + stream.pipe(this, {end: false}); + return; + } + + var value = stream; + this.write(value); + this._getNext(); +}; + +CombinedStream.prototype._handleErrors = function(stream) { + var self = this; + stream.on('error', function(err) { + self._emitError(err); + }); +}; + +CombinedStream.prototype.write = function(data) { + this.emit('data', data); +}; + +CombinedStream.prototype.pause = function() { + if (!this.pauseStreams) { + return; + } + + if(this.pauseStreams && this._currentStream && typeof(this._currentStream.pause) == 'function') this._currentStream.pause(); + this.emit('pause'); +}; + +CombinedStream.prototype.resume = function() { + if (!this._released) { + this._released = true; + this.writable = true; + this._getNext(); + } + + if(this.pauseStreams && this._currentStream && typeof(this._currentStream.resume) == 'function') this._currentStream.resume(); + this.emit('resume'); +}; + +CombinedStream.prototype.end = function() { + this._reset(); + this.emit('end'); +}; + +CombinedStream.prototype.destroy = function() { + this._reset(); + this.emit('close'); +}; + +CombinedStream.prototype._reset = function() { + this.writable = false; + this._streams = []; + this._currentStream = null; +}; + +CombinedStream.prototype._checkDataSize = function() { + this._updateDataSize(); + if (this.dataSize <= this.maxDataSize) { + return; + } + + var message = + 'DelayedStream#maxDataSize of ' + this.maxDataSize + ' bytes exceeded.'; + this._emitError(new Error(message)); +}; + +CombinedStream.prototype._updateDataSize = function() { + this.dataSize = 0; + + var self = this; + this._streams.forEach(function(stream) { + if (!stream.dataSize) { + return; + } + + self.dataSize += stream.dataSize; + }); + + if (this._currentStream && this._currentStream.dataSize) { + this.dataSize += this._currentStream.dataSize; + } +}; + +CombinedStream.prototype._emitError = function(err) { + this._reset(); + this.emit('error', err); +}; + + /***/ }), /***/ "../../node_modules/concat-map/index.js": @@ -24750,6 +26028,120 @@ module.exports = async ( }; +/***/ }), + +/***/ "../../node_modules/delayed-stream/lib/delayed_stream.js": +/***/ (function(module, exports, __webpack_require__) { + +var Stream = __webpack_require__("stream").Stream; +var util = __webpack_require__("util"); + +module.exports = DelayedStream; +function DelayedStream() { + this.source = null; + this.dataSize = 0; + this.maxDataSize = 1024 * 1024; + this.pauseStream = true; + + this._maxDataSizeExceeded = false; + this._released = false; + this._bufferedEvents = []; +} +util.inherits(DelayedStream, Stream); + +DelayedStream.create = function(source, options) { + var delayedStream = new this(); + + options = options || {}; + for (var option in options) { + delayedStream[option] = options[option]; + } + + delayedStream.source = source; + + var realEmit = source.emit; + source.emit = function() { + delayedStream._handleEmit(arguments); + return realEmit.apply(source, arguments); + }; + + source.on('error', function() {}); + if (delayedStream.pauseStream) { + source.pause(); + } + + return delayedStream; +}; + +Object.defineProperty(DelayedStream.prototype, 'readable', { + configurable: true, + enumerable: true, + get: function() { + return this.source.readable; + } +}); + +DelayedStream.prototype.setEncoding = function() { + return this.source.setEncoding.apply(this.source, arguments); +}; + +DelayedStream.prototype.resume = function() { + if (!this._released) { + this.release(); + } + + this.source.resume(); +}; + +DelayedStream.prototype.pause = function() { + this.source.pause(); +}; + +DelayedStream.prototype.release = function() { + this._released = true; + + this._bufferedEvents.forEach(function(args) { + this.emit.apply(this, args); + }.bind(this)); + this._bufferedEvents = []; +}; + +DelayedStream.prototype.pipe = function() { + var r = Stream.prototype.pipe.apply(this, arguments); + this.resume(); + return r; +}; + +DelayedStream.prototype._handleEmit = function(args) { + if (this._released) { + this.emit.apply(this, args); + return; + } + + if (args[0] === 'data') { + this.dataSize += args[1].length; + this._checkIfMaxDataSizeExceeded(); + } + + this._bufferedEvents.push(args); +}; + +DelayedStream.prototype._checkIfMaxDataSizeExceeded = function() { + if (this._maxDataSizeExceeded) { + return; + } + + if (this.dataSize <= this.maxDataSize) { + return; + } + + this._maxDataSizeExceeded = true; + var message = + 'DelayedStream#maxDataSize of ' + this.maxDataSize + ' bytes exceeded.' + this.emit('error', new Error(message)); +}; + + /***/ }), /***/ "../../node_modules/detect-indent/index.js": @@ -26865,7 +28257,7 @@ RedirectableRequest.prototype._performRequest = function () { // If specified, use the agent corresponding to the protocol // (HTTP and HTTPS use different types of agents) if (this._options.agents) { - var scheme = protocol.substr(0, protocol.length - 1); + var scheme = protocol.slice(0, -1); this._options.agent = this._options.agents[scheme]; } @@ -26957,10 +28349,21 @@ RedirectableRequest.prototype._processResponse = function (response) { return; } + // Store the request headers if applicable + var requestHeaders; + var beforeRedirect = this._options.beforeRedirect; + if (beforeRedirect) { + requestHeaders = Object.assign({ + // The Host header was set by nativeProtocol.request + Host: response.req.getHeader("host"), + }, this._options.headers); + } + // RFC7231§6.4: Automatic redirection needs to done with // care for methods not known to be safe, […] // RFC7231§6.4.2–3: For historical reasons, a user agent MAY change // the request method from POST to GET for the subsequent request. + var method = this._options.method; if ((statusCode === 301 || statusCode === 302) && this._options.method === "POST" || // RFC7231§6.4.4: The 303 (See Other) status code indicates that // the server is redirecting the user agent to a different resource […] @@ -27008,10 +28411,18 @@ RedirectableRequest.prototype._processResponse = function (response) { } // Evaluate the beforeRedirect callback - if (typeof this._options.beforeRedirect === "function") { - var responseDetails = { headers: response.headers }; + if (typeof beforeRedirect === "function") { + var responseDetails = { + headers: response.headers, + statusCode: statusCode, + }; + var requestDetails = { + url: currentUrl, + method: method, + headers: requestHeaders, + }; try { - this._options.beforeRedirect.call(null, this._options, responseDetails); + beforeRedirect(this._options, responseDetails, requestDetails); } catch (err) { this.emit("error", err); @@ -27169,6 +28580,531 @@ module.exports = wrap({ http: http, https: https }); module.exports.wrap = wrap; +/***/ }), + +/***/ "../../node_modules/form-data/lib/form_data.js": +/***/ (function(module, exports, __webpack_require__) { + +var CombinedStream = __webpack_require__("../../node_modules/combined-stream/lib/combined_stream.js"); +var util = __webpack_require__("util"); +var path = __webpack_require__("path"); +var http = __webpack_require__("http"); +var https = __webpack_require__("https"); +var parseUrl = __webpack_require__("url").parse; +var fs = __webpack_require__("fs"); +var Stream = __webpack_require__("stream").Stream; +var mime = __webpack_require__("../../node_modules/mime-types/index.js"); +var asynckit = __webpack_require__("../../node_modules/asynckit/index.js"); +var populate = __webpack_require__("../../node_modules/form-data/lib/populate.js"); + +// Public API +module.exports = FormData; + +// make it a Stream +util.inherits(FormData, CombinedStream); + +/** + * Create readable "multipart/form-data" streams. + * Can be used to submit forms + * and file uploads to other web applications. + * + * @constructor + * @param {Object} options - Properties to be added/overriden for FormData and CombinedStream + */ +function FormData(options) { + if (!(this instanceof FormData)) { + return new FormData(options); + } + + this._overheadLength = 0; + this._valueLength = 0; + this._valuesToMeasure = []; + + CombinedStream.call(this); + + options = options || {}; + for (var option in options) { + this[option] = options[option]; + } +} + +FormData.LINE_BREAK = '\r\n'; +FormData.DEFAULT_CONTENT_TYPE = 'application/octet-stream'; + +FormData.prototype.append = function(field, value, options) { + + options = options || {}; + + // allow filename as single option + if (typeof options == 'string') { + options = {filename: options}; + } + + var append = CombinedStream.prototype.append.bind(this); + + // all that streamy business can't handle numbers + if (typeof value == 'number') { + value = '' + value; + } + + // https://github.com/felixge/node-form-data/issues/38 + if (util.isArray(value)) { + // Please convert your array into string + // the way web server expects it + this._error(new Error('Arrays are not supported.')); + return; + } + + var header = this._multiPartHeader(field, value, options); + var footer = this._multiPartFooter(); + + append(header); + append(value); + append(footer); + + // pass along options.knownLength + this._trackLength(header, value, options); +}; + +FormData.prototype._trackLength = function(header, value, options) { + var valueLength = 0; + + // used w/ getLengthSync(), when length is known. + // e.g. for streaming directly from a remote server, + // w/ a known file a size, and not wanting to wait for + // incoming file to finish to get its size. + if (options.knownLength != null) { + valueLength += +options.knownLength; + } else if (Buffer.isBuffer(value)) { + valueLength = value.length; + } else if (typeof value === 'string') { + valueLength = Buffer.byteLength(value); + } + + this._valueLength += valueLength; + + // @check why add CRLF? does this account for custom/multiple CRLFs? + this._overheadLength += + Buffer.byteLength(header) + + FormData.LINE_BREAK.length; + + // empty or either doesn't have path or not an http response or not a stream + if (!value || ( !value.path && !(value.readable && value.hasOwnProperty('httpVersion')) && !(value instanceof Stream))) { + return; + } + + // no need to bother with the length + if (!options.knownLength) { + this._valuesToMeasure.push(value); + } +}; + +FormData.prototype._lengthRetriever = function(value, callback) { + + if (value.hasOwnProperty('fd')) { + + // take read range into a account + // `end` = Infinity –> read file till the end + // + // TODO: Looks like there is bug in Node fs.createReadStream + // it doesn't respect `end` options without `start` options + // Fix it when node fixes it. + // https://github.com/joyent/node/issues/7819 + if (value.end != undefined && value.end != Infinity && value.start != undefined) { + + // when end specified + // no need to calculate range + // inclusive, starts with 0 + callback(null, value.end + 1 - (value.start ? value.start : 0)); + + // not that fast snoopy + } else { + // still need to fetch file size from fs + fs.stat(value.path, function(err, stat) { + + var fileSize; + + if (err) { + callback(err); + return; + } + + // update final size based on the range options + fileSize = stat.size - (value.start ? value.start : 0); + callback(null, fileSize); + }); + } + + // or http response + } else if (value.hasOwnProperty('httpVersion')) { + callback(null, +value.headers['content-length']); + + // or request stream http://github.com/mikeal/request + } else if (value.hasOwnProperty('httpModule')) { + // wait till response come back + value.on('response', function(response) { + value.pause(); + callback(null, +response.headers['content-length']); + }); + value.resume(); + + // something else + } else { + callback('Unknown stream'); + } +}; + +FormData.prototype._multiPartHeader = function(field, value, options) { + // custom header specified (as string)? + // it becomes responsible for boundary + // (e.g. to handle extra CRLFs on .NET servers) + if (typeof options.header == 'string') { + return options.header; + } + + var contentDisposition = this._getContentDisposition(value, options); + var contentType = this._getContentType(value, options); + + var contents = ''; + var headers = { + // add custom disposition as third element or keep it two elements if not + 'Content-Disposition': ['form-data', 'name="' + field + '"'].concat(contentDisposition || []), + // if no content type. allow it to be empty array + 'Content-Type': [].concat(contentType || []) + }; + + // allow custom headers. + if (typeof options.header == 'object') { + populate(headers, options.header); + } + + var header; + for (var prop in headers) { + if (!headers.hasOwnProperty(prop)) continue; + header = headers[prop]; + + // skip nullish headers. + if (header == null) { + continue; + } + + // convert all headers to arrays. + if (!Array.isArray(header)) { + header = [header]; + } + + // add non-empty headers. + if (header.length) { + contents += prop + ': ' + header.join('; ') + FormData.LINE_BREAK; + } + } + + return '--' + this.getBoundary() + FormData.LINE_BREAK + contents + FormData.LINE_BREAK; +}; + +FormData.prototype._getContentDisposition = function(value, options) { + + var filename + , contentDisposition + ; + + if (typeof options.filepath === 'string') { + // custom filepath for relative paths + filename = path.normalize(options.filepath).replace(/\\/g, '/'); + } else if (options.filename || value.name || value.path) { + // custom filename take precedence + // formidable and the browser add a name property + // fs- and request- streams have path property + filename = path.basename(options.filename || value.name || value.path); + } else if (value.readable && value.hasOwnProperty('httpVersion')) { + // or try http response + filename = path.basename(value.client._httpMessage.path || ''); + } + + if (filename) { + contentDisposition = 'filename="' + filename + '"'; + } + + return contentDisposition; +}; + +FormData.prototype._getContentType = function(value, options) { + + // use custom content-type above all + var contentType = options.contentType; + + // or try `name` from formidable, browser + if (!contentType && value.name) { + contentType = mime.lookup(value.name); + } + + // or try `path` from fs-, request- streams + if (!contentType && value.path) { + contentType = mime.lookup(value.path); + } + + // or if it's http-reponse + if (!contentType && value.readable && value.hasOwnProperty('httpVersion')) { + contentType = value.headers['content-type']; + } + + // or guess it from the filepath or filename + if (!contentType && (options.filepath || options.filename)) { + contentType = mime.lookup(options.filepath || options.filename); + } + + // fallback to the default content type if `value` is not simple value + if (!contentType && typeof value == 'object') { + contentType = FormData.DEFAULT_CONTENT_TYPE; + } + + return contentType; +}; + +FormData.prototype._multiPartFooter = function() { + return function(next) { + var footer = FormData.LINE_BREAK; + + var lastPart = (this._streams.length === 0); + if (lastPart) { + footer += this._lastBoundary(); + } + + next(footer); + }.bind(this); +}; + +FormData.prototype._lastBoundary = function() { + return '--' + this.getBoundary() + '--' + FormData.LINE_BREAK; +}; + +FormData.prototype.getHeaders = function(userHeaders) { + var header; + var formHeaders = { + 'content-type': 'multipart/form-data; boundary=' + this.getBoundary() + }; + + for (header in userHeaders) { + if (userHeaders.hasOwnProperty(header)) { + formHeaders[header.toLowerCase()] = userHeaders[header]; + } + } + + return formHeaders; +}; + +FormData.prototype.setBoundary = function(boundary) { + this._boundary = boundary; +}; + +FormData.prototype.getBoundary = function() { + if (!this._boundary) { + this._generateBoundary(); + } + + return this._boundary; +}; + +FormData.prototype.getBuffer = function() { + var dataBuffer = new Buffer.alloc( 0 ); + var boundary = this.getBoundary(); + + // Create the form content. Add Line breaks to the end of data. + for (var i = 0, len = this._streams.length; i < len; i++) { + if (typeof this._streams[i] !== 'function') { + + // Add content to the buffer. + if(Buffer.isBuffer(this._streams[i])) { + dataBuffer = Buffer.concat( [dataBuffer, this._streams[i]]); + }else { + dataBuffer = Buffer.concat( [dataBuffer, Buffer.from(this._streams[i])]); + } + + // Add break after content. + if (typeof this._streams[i] !== 'string' || this._streams[i].substring( 2, boundary.length + 2 ) !== boundary) { + dataBuffer = Buffer.concat( [dataBuffer, Buffer.from(FormData.LINE_BREAK)] ); + } + } + } + + // Add the footer and return the Buffer object. + return Buffer.concat( [dataBuffer, Buffer.from(this._lastBoundary())] ); +}; + +FormData.prototype._generateBoundary = function() { + // This generates a 50 character boundary similar to those used by Firefox. + // They are optimized for boyer-moore parsing. + var boundary = '--------------------------'; + for (var i = 0; i < 24; i++) { + boundary += Math.floor(Math.random() * 10).toString(16); + } + + this._boundary = boundary; +}; + +// Note: getLengthSync DOESN'T calculate streams length +// As workaround one can calculate file size manually +// and add it as knownLength option +FormData.prototype.getLengthSync = function() { + var knownLength = this._overheadLength + this._valueLength; + + // Don't get confused, there are 3 "internal" streams for each keyval pair + // so it basically checks if there is any value added to the form + if (this._streams.length) { + knownLength += this._lastBoundary().length; + } + + // https://github.com/form-data/form-data/issues/40 + if (!this.hasKnownLength()) { + // Some async length retrievers are present + // therefore synchronous length calculation is false. + // Please use getLength(callback) to get proper length + this._error(new Error('Cannot calculate proper length in synchronous way.')); + } + + return knownLength; +}; + +// Public API to check if length of added values is known +// https://github.com/form-data/form-data/issues/196 +// https://github.com/form-data/form-data/issues/262 +FormData.prototype.hasKnownLength = function() { + var hasKnownLength = true; + + if (this._valuesToMeasure.length) { + hasKnownLength = false; + } + + return hasKnownLength; +}; + +FormData.prototype.getLength = function(cb) { + var knownLength = this._overheadLength + this._valueLength; + + if (this._streams.length) { + knownLength += this._lastBoundary().length; + } + + if (!this._valuesToMeasure.length) { + process.nextTick(cb.bind(this, null, knownLength)); + return; + } + + asynckit.parallel(this._valuesToMeasure, this._lengthRetriever, function(err, values) { + if (err) { + cb(err); + return; + } + + values.forEach(function(length) { + knownLength += length; + }); + + cb(null, knownLength); + }); +}; + +FormData.prototype.submit = function(params, cb) { + var request + , options + , defaults = {method: 'post'} + ; + + // parse provided url if it's string + // or treat it as options object + if (typeof params == 'string') { + + params = parseUrl(params); + options = populate({ + port: params.port, + path: params.pathname, + host: params.hostname, + protocol: params.protocol + }, defaults); + + // use custom params + } else { + + options = populate(params, defaults); + // if no port provided use default one + if (!options.port) { + options.port = options.protocol == 'https:' ? 443 : 80; + } + } + + // put that good code in getHeaders to some use + options.headers = this.getHeaders(params.headers); + + // https if specified, fallback to http in any other case + if (options.protocol == 'https:') { + request = https.request(options); + } else { + request = http.request(options); + } + + // get content length and fire away + this.getLength(function(err, length) { + if (err && err !== 'Unknown stream') { + this._error(err); + return; + } + + // add content length + if (length) { + request.setHeader('Content-Length', length); + } + + this.pipe(request); + if (cb) { + var onResponse; + + var callback = function (error, responce) { + request.removeListener('error', callback); + request.removeListener('response', onResponse); + + return cb.call(this, error, responce); + }; + + onResponse = callback.bind(this, null); + + request.on('error', callback); + request.on('response', onResponse); + } + }.bind(this)); + + return request; +}; + +FormData.prototype._error = function(err) { + if (!this.error) { + this.error = err; + this.pause(); + this.emit('error', err); + } +}; + +FormData.prototype.toString = function () { + return '[object FormData]'; +}; + + +/***/ }), + +/***/ "../../node_modules/form-data/lib/populate.js": +/***/ (function(module, exports) { + +// populates missing values +module.exports = function(dst, src) { + + Object.keys(src).forEach(function(prop) + { + dst[prop] = dst[prop] || src[prop]; + }); + + return dst; +}; + + /***/ }), /***/ "../../node_modules/fs.realpath/index.js": @@ -35073,6 +37009,227 @@ function pauseStreams (streams, options) { } +/***/ }), + +/***/ "../../node_modules/mime-db/db.json": +/***/ (function(module) { + +module.exports = JSON.parse("{\"application/1d-interleaved-parityfec\":{\"source\":\"iana\"},\"application/3gpdash-qoe-report+xml\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true},\"application/3gpp-ims+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/a2l\":{\"source\":\"iana\"},\"application/activemessage\":{\"source\":\"iana\"},\"application/activity+json\":{\"source\":\"iana\",\"compressible\":true},\"application/alto-costmap+json\":{\"source\":\"iana\",\"compressible\":true},\"application/alto-costmapfilter+json\":{\"source\":\"iana\",\"compressible\":true},\"application/alto-directory+json\":{\"source\":\"iana\",\"compressible\":true},\"application/alto-endpointcost+json\":{\"source\":\"iana\",\"compressible\":true},\"application/alto-endpointcostparams+json\":{\"source\":\"iana\",\"compressible\":true},\"application/alto-endpointprop+json\":{\"source\":\"iana\",\"compressible\":true},\"application/alto-endpointpropparams+json\":{\"source\":\"iana\",\"compressible\":true},\"application/alto-error+json\":{\"source\":\"iana\",\"compressible\":true},\"application/alto-networkmap+json\":{\"source\":\"iana\",\"compressible\":true},\"application/alto-networkmapfilter+json\":{\"source\":\"iana\",\"compressible\":true},\"application/alto-updatestreamcontrol+json\":{\"source\":\"iana\",\"compressible\":true},\"application/alto-updatestreamparams+json\":{\"source\":\"iana\",\"compressible\":true},\"application/aml\":{\"source\":\"iana\"},\"application/andrew-inset\":{\"source\":\"iana\",\"extensions\":[\"ez\"]},\"application/applefile\":{\"source\":\"iana\"},\"application/applixware\":{\"source\":\"apache\",\"extensions\":[\"aw\"]},\"application/atf\":{\"source\":\"iana\"},\"application/atfx\":{\"source\":\"iana\"},\"application/atom+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"atom\"]},\"application/atomcat+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"atomcat\"]},\"application/atomdeleted+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"atomdeleted\"]},\"application/atomicmail\":{\"source\":\"iana\"},\"application/atomsvc+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"atomsvc\"]},\"application/atsc-dwd+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"dwd\"]},\"application/atsc-dynamic-event-message\":{\"source\":\"iana\"},\"application/atsc-held+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"held\"]},\"application/atsc-rdt+json\":{\"source\":\"iana\",\"compressible\":true},\"application/atsc-rsat+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"rsat\"]},\"application/atxml\":{\"source\":\"iana\"},\"application/auth-policy+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/bacnet-xdd+zip\":{\"source\":\"iana\",\"compressible\":false},\"application/batch-smtp\":{\"source\":\"iana\"},\"application/bdoc\":{\"compressible\":false,\"extensions\":[\"bdoc\"]},\"application/beep+xml\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true},\"application/calendar+json\":{\"source\":\"iana\",\"compressible\":true},\"application/calendar+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"xcs\"]},\"application/call-completion\":{\"source\":\"iana\"},\"application/cals-1840\":{\"source\":\"iana\"},\"application/cap+xml\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true},\"application/cbor\":{\"source\":\"iana\"},\"application/cbor-seq\":{\"source\":\"iana\"},\"application/cccex\":{\"source\":\"iana\"},\"application/ccmp+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/ccxml+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"ccxml\"]},\"application/cdfx+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"cdfx\"]},\"application/cdmi-capability\":{\"source\":\"iana\",\"extensions\":[\"cdmia\"]},\"application/cdmi-container\":{\"source\":\"iana\",\"extensions\":[\"cdmic\"]},\"application/cdmi-domain\":{\"source\":\"iana\",\"extensions\":[\"cdmid\"]},\"application/cdmi-object\":{\"source\":\"iana\",\"extensions\":[\"cdmio\"]},\"application/cdmi-queue\":{\"source\":\"iana\",\"extensions\":[\"cdmiq\"]},\"application/cdni\":{\"source\":\"iana\"},\"application/cea\":{\"source\":\"iana\"},\"application/cea-2018+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/cellml+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/cfw\":{\"source\":\"iana\"},\"application/clue+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/clue_info+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/cms\":{\"source\":\"iana\"},\"application/cnrp+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/coap-group+json\":{\"source\":\"iana\",\"compressible\":true},\"application/coap-payload\":{\"source\":\"iana\"},\"application/commonground\":{\"source\":\"iana\"},\"application/conference-info+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/cose\":{\"source\":\"iana\"},\"application/cose-key\":{\"source\":\"iana\"},\"application/cose-key-set\":{\"source\":\"iana\"},\"application/cpl+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/csrattrs\":{\"source\":\"iana\"},\"application/csta+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/cstadata+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/csvm+json\":{\"source\":\"iana\",\"compressible\":true},\"application/cu-seeme\":{\"source\":\"apache\",\"extensions\":[\"cu\"]},\"application/cwt\":{\"source\":\"iana\"},\"application/cybercash\":{\"source\":\"iana\"},\"application/dart\":{\"compressible\":true},\"application/dash+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"mpd\"]},\"application/dashdelta\":{\"source\":\"iana\"},\"application/davmount+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"davmount\"]},\"application/dca-rft\":{\"source\":\"iana\"},\"application/dcd\":{\"source\":\"iana\"},\"application/dec-dx\":{\"source\":\"iana\"},\"application/dialog-info+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/dicom\":{\"source\":\"iana\"},\"application/dicom+json\":{\"source\":\"iana\",\"compressible\":true},\"application/dicom+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/dii\":{\"source\":\"iana\"},\"application/dit\":{\"source\":\"iana\"},\"application/dns\":{\"source\":\"iana\"},\"application/dns+json\":{\"source\":\"iana\",\"compressible\":true},\"application/dns-message\":{\"source\":\"iana\"},\"application/docbook+xml\":{\"source\":\"apache\",\"compressible\":true,\"extensions\":[\"dbk\"]},\"application/dots+cbor\":{\"source\":\"iana\"},\"application/dskpp+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/dssc+der\":{\"source\":\"iana\",\"extensions\":[\"dssc\"]},\"application/dssc+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"xdssc\"]},\"application/dvcs\":{\"source\":\"iana\"},\"application/ecmascript\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"ecma\",\"es\"]},\"application/edi-consent\":{\"source\":\"iana\"},\"application/edi-x12\":{\"source\":\"iana\",\"compressible\":false},\"application/edifact\":{\"source\":\"iana\",\"compressible\":false},\"application/efi\":{\"source\":\"iana\"},\"application/emergencycalldata.comment+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/emergencycalldata.control+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/emergencycalldata.deviceinfo+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/emergencycalldata.ecall.msd\":{\"source\":\"iana\"},\"application/emergencycalldata.providerinfo+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/emergencycalldata.serviceinfo+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/emergencycalldata.subscriberinfo+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/emergencycalldata.veds+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/emma+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"emma\"]},\"application/emotionml+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"emotionml\"]},\"application/encaprtp\":{\"source\":\"iana\"},\"application/epp+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/epub+zip\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"epub\"]},\"application/eshop\":{\"source\":\"iana\"},\"application/exi\":{\"source\":\"iana\",\"extensions\":[\"exi\"]},\"application/expect-ct-report+json\":{\"source\":\"iana\",\"compressible\":true},\"application/fastinfoset\":{\"source\":\"iana\"},\"application/fastsoap\":{\"source\":\"iana\"},\"application/fdt+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"fdt\"]},\"application/fhir+json\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true},\"application/fhir+xml\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true},\"application/fido.trusted-apps+json\":{\"compressible\":true},\"application/fits\":{\"source\":\"iana\"},\"application/flexfec\":{\"source\":\"iana\"},\"application/font-sfnt\":{\"source\":\"iana\"},\"application/font-tdpfr\":{\"source\":\"iana\",\"extensions\":[\"pfr\"]},\"application/font-woff\":{\"source\":\"iana\",\"compressible\":false},\"application/framework-attributes+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/geo+json\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"geojson\"]},\"application/geo+json-seq\":{\"source\":\"iana\"},\"application/geopackage+sqlite3\":{\"source\":\"iana\"},\"application/geoxacml+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/gltf-buffer\":{\"source\":\"iana\"},\"application/gml+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"gml\"]},\"application/gpx+xml\":{\"source\":\"apache\",\"compressible\":true,\"extensions\":[\"gpx\"]},\"application/gxf\":{\"source\":\"apache\",\"extensions\":[\"gxf\"]},\"application/gzip\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"gz\"]},\"application/h224\":{\"source\":\"iana\"},\"application/held+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/hjson\":{\"extensions\":[\"hjson\"]},\"application/http\":{\"source\":\"iana\"},\"application/hyperstudio\":{\"source\":\"iana\",\"extensions\":[\"stk\"]},\"application/ibe-key-request+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/ibe-pkg-reply+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/ibe-pp-data\":{\"source\":\"iana\"},\"application/iges\":{\"source\":\"iana\"},\"application/im-iscomposing+xml\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true},\"application/index\":{\"source\":\"iana\"},\"application/index.cmd\":{\"source\":\"iana\"},\"application/index.obj\":{\"source\":\"iana\"},\"application/index.response\":{\"source\":\"iana\"},\"application/index.vnd\":{\"source\":\"iana\"},\"application/inkml+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"ink\",\"inkml\"]},\"application/iotp\":{\"source\":\"iana\"},\"application/ipfix\":{\"source\":\"iana\",\"extensions\":[\"ipfix\"]},\"application/ipp\":{\"source\":\"iana\"},\"application/isup\":{\"source\":\"iana\"},\"application/its+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"its\"]},\"application/java-archive\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"jar\",\"war\",\"ear\"]},\"application/java-serialized-object\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"ser\"]},\"application/java-vm\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"class\"]},\"application/javascript\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true,\"extensions\":[\"js\",\"mjs\"]},\"application/jf2feed+json\":{\"source\":\"iana\",\"compressible\":true},\"application/jose\":{\"source\":\"iana\"},\"application/jose+json\":{\"source\":\"iana\",\"compressible\":true},\"application/jrd+json\":{\"source\":\"iana\",\"compressible\":true},\"application/json\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true,\"extensions\":[\"json\",\"map\"]},\"application/json-patch+json\":{\"source\":\"iana\",\"compressible\":true},\"application/json-seq\":{\"source\":\"iana\"},\"application/json5\":{\"extensions\":[\"json5\"]},\"application/jsonml+json\":{\"source\":\"apache\",\"compressible\":true,\"extensions\":[\"jsonml\"]},\"application/jwk+json\":{\"source\":\"iana\",\"compressible\":true},\"application/jwk-set+json\":{\"source\":\"iana\",\"compressible\":true},\"application/jwt\":{\"source\":\"iana\"},\"application/kpml-request+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/kpml-response+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/ld+json\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"jsonld\"]},\"application/lgr+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"lgr\"]},\"application/link-format\":{\"source\":\"iana\"},\"application/load-control+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/lost+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"lostxml\"]},\"application/lostsync+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/lpf+zip\":{\"source\":\"iana\",\"compressible\":false},\"application/lxf\":{\"source\":\"iana\"},\"application/mac-binhex40\":{\"source\":\"iana\",\"extensions\":[\"hqx\"]},\"application/mac-compactpro\":{\"source\":\"apache\",\"extensions\":[\"cpt\"]},\"application/macwriteii\":{\"source\":\"iana\"},\"application/mads+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"mads\"]},\"application/manifest+json\":{\"charset\":\"UTF-8\",\"compressible\":true,\"extensions\":[\"webmanifest\"]},\"application/marc\":{\"source\":\"iana\",\"extensions\":[\"mrc\"]},\"application/marcxml+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"mrcx\"]},\"application/mathematica\":{\"source\":\"iana\",\"extensions\":[\"ma\",\"nb\",\"mb\"]},\"application/mathml+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"mathml\"]},\"application/mathml-content+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/mathml-presentation+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/mbms-associated-procedure-description+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/mbms-deregister+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/mbms-envelope+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/mbms-msk+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/mbms-msk-response+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/mbms-protection-description+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/mbms-reception-report+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/mbms-register+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/mbms-register-response+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/mbms-schedule+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/mbms-user-service-description+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/mbox\":{\"source\":\"iana\",\"extensions\":[\"mbox\"]},\"application/media-policy-dataset+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/media_control+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/mediaservercontrol+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"mscml\"]},\"application/merge-patch+json\":{\"source\":\"iana\",\"compressible\":true},\"application/metalink+xml\":{\"source\":\"apache\",\"compressible\":true,\"extensions\":[\"metalink\"]},\"application/metalink4+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"meta4\"]},\"application/mets+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"mets\"]},\"application/mf4\":{\"source\":\"iana\"},\"application/mikey\":{\"source\":\"iana\"},\"application/mipc\":{\"source\":\"iana\"},\"application/mmt-aei+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"maei\"]},\"application/mmt-usd+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"musd\"]},\"application/mods+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"mods\"]},\"application/moss-keys\":{\"source\":\"iana\"},\"application/moss-signature\":{\"source\":\"iana\"},\"application/mosskey-data\":{\"source\":\"iana\"},\"application/mosskey-request\":{\"source\":\"iana\"},\"application/mp21\":{\"source\":\"iana\",\"extensions\":[\"m21\",\"mp21\"]},\"application/mp4\":{\"source\":\"iana\",\"extensions\":[\"mp4s\",\"m4p\"]},\"application/mpeg4-generic\":{\"source\":\"iana\"},\"application/mpeg4-iod\":{\"source\":\"iana\"},\"application/mpeg4-iod-xmt\":{\"source\":\"iana\"},\"application/mrb-consumer+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"xdf\"]},\"application/mrb-publish+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"xdf\"]},\"application/msc-ivr+xml\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true},\"application/msc-mixer+xml\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true},\"application/msword\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"doc\",\"dot\"]},\"application/mud+json\":{\"source\":\"iana\",\"compressible\":true},\"application/multipart-core\":{\"source\":\"iana\"},\"application/mxf\":{\"source\":\"iana\",\"extensions\":[\"mxf\"]},\"application/n-quads\":{\"source\":\"iana\",\"extensions\":[\"nq\"]},\"application/n-triples\":{\"source\":\"iana\",\"extensions\":[\"nt\"]},\"application/nasdata\":{\"source\":\"iana\"},\"application/news-checkgroups\":{\"source\":\"iana\",\"charset\":\"US-ASCII\"},\"application/news-groupinfo\":{\"source\":\"iana\",\"charset\":\"US-ASCII\"},\"application/news-transmission\":{\"source\":\"iana\"},\"application/nlsml+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/node\":{\"source\":\"iana\",\"extensions\":[\"cjs\"]},\"application/nss\":{\"source\":\"iana\"},\"application/ocsp-request\":{\"source\":\"iana\"},\"application/ocsp-response\":{\"source\":\"iana\"},\"application/octet-stream\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"bin\",\"dms\",\"lrf\",\"mar\",\"so\",\"dist\",\"distz\",\"pkg\",\"bpk\",\"dump\",\"elc\",\"deploy\",\"exe\",\"dll\",\"deb\",\"dmg\",\"iso\",\"img\",\"msi\",\"msp\",\"msm\",\"buffer\"]},\"application/oda\":{\"source\":\"iana\",\"extensions\":[\"oda\"]},\"application/odm+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/odx\":{\"source\":\"iana\"},\"application/oebps-package+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"opf\"]},\"application/ogg\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"ogx\"]},\"application/omdoc+xml\":{\"source\":\"apache\",\"compressible\":true,\"extensions\":[\"omdoc\"]},\"application/onenote\":{\"source\":\"apache\",\"extensions\":[\"onetoc\",\"onetoc2\",\"onetmp\",\"onepkg\"]},\"application/oscore\":{\"source\":\"iana\"},\"application/oxps\":{\"source\":\"iana\",\"extensions\":[\"oxps\"]},\"application/p2p-overlay+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"relo\"]},\"application/parityfec\":{\"source\":\"iana\"},\"application/passport\":{\"source\":\"iana\"},\"application/patch-ops-error+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"xer\"]},\"application/pdf\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"pdf\"]},\"application/pdx\":{\"source\":\"iana\"},\"application/pem-certificate-chain\":{\"source\":\"iana\"},\"application/pgp-encrypted\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"pgp\"]},\"application/pgp-keys\":{\"source\":\"iana\"},\"application/pgp-signature\":{\"source\":\"iana\",\"extensions\":[\"asc\",\"sig\"]},\"application/pics-rules\":{\"source\":\"apache\",\"extensions\":[\"prf\"]},\"application/pidf+xml\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true},\"application/pidf-diff+xml\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true},\"application/pkcs10\":{\"source\":\"iana\",\"extensions\":[\"p10\"]},\"application/pkcs12\":{\"source\":\"iana\"},\"application/pkcs7-mime\":{\"source\":\"iana\",\"extensions\":[\"p7m\",\"p7c\"]},\"application/pkcs7-signature\":{\"source\":\"iana\",\"extensions\":[\"p7s\"]},\"application/pkcs8\":{\"source\":\"iana\",\"extensions\":[\"p8\"]},\"application/pkcs8-encrypted\":{\"source\":\"iana\"},\"application/pkix-attr-cert\":{\"source\":\"iana\",\"extensions\":[\"ac\"]},\"application/pkix-cert\":{\"source\":\"iana\",\"extensions\":[\"cer\"]},\"application/pkix-crl\":{\"source\":\"iana\",\"extensions\":[\"crl\"]},\"application/pkix-pkipath\":{\"source\":\"iana\",\"extensions\":[\"pkipath\"]},\"application/pkixcmp\":{\"source\":\"iana\",\"extensions\":[\"pki\"]},\"application/pls+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"pls\"]},\"application/poc-settings+xml\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true},\"application/postscript\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"ai\",\"eps\",\"ps\"]},\"application/ppsp-tracker+json\":{\"source\":\"iana\",\"compressible\":true},\"application/problem+json\":{\"source\":\"iana\",\"compressible\":true},\"application/problem+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/provenance+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"provx\"]},\"application/prs.alvestrand.titrax-sheet\":{\"source\":\"iana\"},\"application/prs.cww\":{\"source\":\"iana\",\"extensions\":[\"cww\"]},\"application/prs.hpub+zip\":{\"source\":\"iana\",\"compressible\":false},\"application/prs.nprend\":{\"source\":\"iana\"},\"application/prs.plucker\":{\"source\":\"iana\"},\"application/prs.rdf-xml-crypt\":{\"source\":\"iana\"},\"application/prs.xsf+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/pskc+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"pskcxml\"]},\"application/pvd+json\":{\"source\":\"iana\",\"compressible\":true},\"application/qsig\":{\"source\":\"iana\"},\"application/raml+yaml\":{\"compressible\":true,\"extensions\":[\"raml\"]},\"application/raptorfec\":{\"source\":\"iana\"},\"application/rdap+json\":{\"source\":\"iana\",\"compressible\":true},\"application/rdf+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"rdf\",\"owl\"]},\"application/reginfo+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"rif\"]},\"application/relax-ng-compact-syntax\":{\"source\":\"iana\",\"extensions\":[\"rnc\"]},\"application/remote-printing\":{\"source\":\"iana\"},\"application/reputon+json\":{\"source\":\"iana\",\"compressible\":true},\"application/resource-lists+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"rl\"]},\"application/resource-lists-diff+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"rld\"]},\"application/rfc+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/riscos\":{\"source\":\"iana\"},\"application/rlmi+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/rls-services+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"rs\"]},\"application/route-apd+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"rapd\"]},\"application/route-s-tsid+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"sls\"]},\"application/route-usd+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"rusd\"]},\"application/rpki-ghostbusters\":{\"source\":\"iana\",\"extensions\":[\"gbr\"]},\"application/rpki-manifest\":{\"source\":\"iana\",\"extensions\":[\"mft\"]},\"application/rpki-publication\":{\"source\":\"iana\"},\"application/rpki-roa\":{\"source\":\"iana\",\"extensions\":[\"roa\"]},\"application/rpki-updown\":{\"source\":\"iana\"},\"application/rsd+xml\":{\"source\":\"apache\",\"compressible\":true,\"extensions\":[\"rsd\"]},\"application/rss+xml\":{\"source\":\"apache\",\"compressible\":true,\"extensions\":[\"rss\"]},\"application/rtf\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"rtf\"]},\"application/rtploopback\":{\"source\":\"iana\"},\"application/rtx\":{\"source\":\"iana\"},\"application/samlassertion+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/samlmetadata+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/sbe\":{\"source\":\"iana\"},\"application/sbml+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"sbml\"]},\"application/scaip+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/scim+json\":{\"source\":\"iana\",\"compressible\":true},\"application/scvp-cv-request\":{\"source\":\"iana\",\"extensions\":[\"scq\"]},\"application/scvp-cv-response\":{\"source\":\"iana\",\"extensions\":[\"scs\"]},\"application/scvp-vp-request\":{\"source\":\"iana\",\"extensions\":[\"spq\"]},\"application/scvp-vp-response\":{\"source\":\"iana\",\"extensions\":[\"spp\"]},\"application/sdp\":{\"source\":\"iana\",\"extensions\":[\"sdp\"]},\"application/secevent+jwt\":{\"source\":\"iana\"},\"application/senml+cbor\":{\"source\":\"iana\"},\"application/senml+json\":{\"source\":\"iana\",\"compressible\":true},\"application/senml+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"senmlx\"]},\"application/senml-etch+cbor\":{\"source\":\"iana\"},\"application/senml-etch+json\":{\"source\":\"iana\",\"compressible\":true},\"application/senml-exi\":{\"source\":\"iana\"},\"application/sensml+cbor\":{\"source\":\"iana\"},\"application/sensml+json\":{\"source\":\"iana\",\"compressible\":true},\"application/sensml+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"sensmlx\"]},\"application/sensml-exi\":{\"source\":\"iana\"},\"application/sep+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/sep-exi\":{\"source\":\"iana\"},\"application/session-info\":{\"source\":\"iana\"},\"application/set-payment\":{\"source\":\"iana\"},\"application/set-payment-initiation\":{\"source\":\"iana\",\"extensions\":[\"setpay\"]},\"application/set-registration\":{\"source\":\"iana\"},\"application/set-registration-initiation\":{\"source\":\"iana\",\"extensions\":[\"setreg\"]},\"application/sgml\":{\"source\":\"iana\"},\"application/sgml-open-catalog\":{\"source\":\"iana\"},\"application/shf+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"shf\"]},\"application/sieve\":{\"source\":\"iana\",\"extensions\":[\"siv\",\"sieve\"]},\"application/simple-filter+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/simple-message-summary\":{\"source\":\"iana\"},\"application/simplesymbolcontainer\":{\"source\":\"iana\"},\"application/sipc\":{\"source\":\"iana\"},\"application/slate\":{\"source\":\"iana\"},\"application/smil\":{\"source\":\"iana\"},\"application/smil+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"smi\",\"smil\"]},\"application/smpte336m\":{\"source\":\"iana\"},\"application/soap+fastinfoset\":{\"source\":\"iana\"},\"application/soap+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/sparql-query\":{\"source\":\"iana\",\"extensions\":[\"rq\"]},\"application/sparql-results+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"srx\"]},\"application/spirits-event+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/sql\":{\"source\":\"iana\"},\"application/srgs\":{\"source\":\"iana\",\"extensions\":[\"gram\"]},\"application/srgs+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"grxml\"]},\"application/sru+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"sru\"]},\"application/ssdl+xml\":{\"source\":\"apache\",\"compressible\":true,\"extensions\":[\"ssdl\"]},\"application/ssml+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"ssml\"]},\"application/stix+json\":{\"source\":\"iana\",\"compressible\":true},\"application/swid+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"swidtag\"]},\"application/tamp-apex-update\":{\"source\":\"iana\"},\"application/tamp-apex-update-confirm\":{\"source\":\"iana\"},\"application/tamp-community-update\":{\"source\":\"iana\"},\"application/tamp-community-update-confirm\":{\"source\":\"iana\"},\"application/tamp-error\":{\"source\":\"iana\"},\"application/tamp-sequence-adjust\":{\"source\":\"iana\"},\"application/tamp-sequence-adjust-confirm\":{\"source\":\"iana\"},\"application/tamp-status-query\":{\"source\":\"iana\"},\"application/tamp-status-response\":{\"source\":\"iana\"},\"application/tamp-update\":{\"source\":\"iana\"},\"application/tamp-update-confirm\":{\"source\":\"iana\"},\"application/tar\":{\"compressible\":true},\"application/taxii+json\":{\"source\":\"iana\",\"compressible\":true},\"application/td+json\":{\"source\":\"iana\",\"compressible\":true},\"application/tei+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"tei\",\"teicorpus\"]},\"application/tetra_isi\":{\"source\":\"iana\"},\"application/thraud+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"tfi\"]},\"application/timestamp-query\":{\"source\":\"iana\"},\"application/timestamp-reply\":{\"source\":\"iana\"},\"application/timestamped-data\":{\"source\":\"iana\",\"extensions\":[\"tsd\"]},\"application/tlsrpt+gzip\":{\"source\":\"iana\"},\"application/tlsrpt+json\":{\"source\":\"iana\",\"compressible\":true},\"application/tnauthlist\":{\"source\":\"iana\"},\"application/toml\":{\"compressible\":true,\"extensions\":[\"toml\"]},\"application/trickle-ice-sdpfrag\":{\"source\":\"iana\"},\"application/trig\":{\"source\":\"iana\"},\"application/ttml+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"ttml\"]},\"application/tve-trigger\":{\"source\":\"iana\"},\"application/tzif\":{\"source\":\"iana\"},\"application/tzif-leap\":{\"source\":\"iana\"},\"application/ulpfec\":{\"source\":\"iana\"},\"application/urc-grpsheet+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/urc-ressheet+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"rsheet\"]},\"application/urc-targetdesc+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/urc-uisocketdesc+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vcard+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vcard+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vemmi\":{\"source\":\"iana\"},\"application/vividence.scriptfile\":{\"source\":\"apache\"},\"application/vnd.1000minds.decision-model+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"1km\"]},\"application/vnd.3gpp-prose+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp-prose-pc3ch+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp-v2x-local-service-information\":{\"source\":\"iana\"},\"application/vnd.3gpp.access-transfer-events+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.bsf+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.gmop+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mc-signalling-ear\":{\"source\":\"iana\"},\"application/vnd.3gpp.mcdata-affiliation-command+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcdata-info+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcdata-payload\":{\"source\":\"iana\"},\"application/vnd.3gpp.mcdata-service-config+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcdata-signalling\":{\"source\":\"iana\"},\"application/vnd.3gpp.mcdata-ue-config+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcdata-user-profile+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcptt-affiliation-command+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcptt-floor-request+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcptt-info+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcptt-location-info+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcptt-mbms-usage-info+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcptt-service-config+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcptt-signed+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcptt-ue-config+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcptt-ue-init-config+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcptt-user-profile+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcvideo-affiliation-command+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcvideo-affiliation-info+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcvideo-info+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcvideo-location-info+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcvideo-mbms-usage-info+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcvideo-service-config+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcvideo-transmission-request+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcvideo-ue-config+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mcvideo-user-profile+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.mid-call+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.pic-bw-large\":{\"source\":\"iana\",\"extensions\":[\"plb\"]},\"application/vnd.3gpp.pic-bw-small\":{\"source\":\"iana\",\"extensions\":[\"psb\"]},\"application/vnd.3gpp.pic-bw-var\":{\"source\":\"iana\",\"extensions\":[\"pvb\"]},\"application/vnd.3gpp.sms\":{\"source\":\"iana\"},\"application/vnd.3gpp.sms+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.srvcc-ext+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.srvcc-info+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.state-and-event-info+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp.ussd+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp2.bcmcsinfo+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.3gpp2.sms\":{\"source\":\"iana\"},\"application/vnd.3gpp2.tcap\":{\"source\":\"iana\",\"extensions\":[\"tcap\"]},\"application/vnd.3lightssoftware.imagescal\":{\"source\":\"iana\"},\"application/vnd.3m.post-it-notes\":{\"source\":\"iana\",\"extensions\":[\"pwn\"]},\"application/vnd.accpac.simply.aso\":{\"source\":\"iana\",\"extensions\":[\"aso\"]},\"application/vnd.accpac.simply.imp\":{\"source\":\"iana\",\"extensions\":[\"imp\"]},\"application/vnd.acucobol\":{\"source\":\"iana\",\"extensions\":[\"acu\"]},\"application/vnd.acucorp\":{\"source\":\"iana\",\"extensions\":[\"atc\",\"acutc\"]},\"application/vnd.adobe.air-application-installer-package+zip\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"air\"]},\"application/vnd.adobe.flash.movie\":{\"source\":\"iana\"},\"application/vnd.adobe.formscentral.fcdt\":{\"source\":\"iana\",\"extensions\":[\"fcdt\"]},\"application/vnd.adobe.fxp\":{\"source\":\"iana\",\"extensions\":[\"fxp\",\"fxpl\"]},\"application/vnd.adobe.partial-upload\":{\"source\":\"iana\"},\"application/vnd.adobe.xdp+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"xdp\"]},\"application/vnd.adobe.xfdf\":{\"source\":\"iana\",\"extensions\":[\"xfdf\"]},\"application/vnd.aether.imp\":{\"source\":\"iana\"},\"application/vnd.afpc.afplinedata\":{\"source\":\"iana\"},\"application/vnd.afpc.afplinedata-pagedef\":{\"source\":\"iana\"},\"application/vnd.afpc.foca-charset\":{\"source\":\"iana\"},\"application/vnd.afpc.foca-codedfont\":{\"source\":\"iana\"},\"application/vnd.afpc.foca-codepage\":{\"source\":\"iana\"},\"application/vnd.afpc.modca\":{\"source\":\"iana\"},\"application/vnd.afpc.modca-formdef\":{\"source\":\"iana\"},\"application/vnd.afpc.modca-mediummap\":{\"source\":\"iana\"},\"application/vnd.afpc.modca-objectcontainer\":{\"source\":\"iana\"},\"application/vnd.afpc.modca-overlay\":{\"source\":\"iana\"},\"application/vnd.afpc.modca-pagesegment\":{\"source\":\"iana\"},\"application/vnd.ah-barcode\":{\"source\":\"iana\"},\"application/vnd.ahead.space\":{\"source\":\"iana\",\"extensions\":[\"ahead\"]},\"application/vnd.airzip.filesecure.azf\":{\"source\":\"iana\",\"extensions\":[\"azf\"]},\"application/vnd.airzip.filesecure.azs\":{\"source\":\"iana\",\"extensions\":[\"azs\"]},\"application/vnd.amadeus+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.amazon.ebook\":{\"source\":\"apache\",\"extensions\":[\"azw\"]},\"application/vnd.amazon.mobi8-ebook\":{\"source\":\"iana\"},\"application/vnd.americandynamics.acc\":{\"source\":\"iana\",\"extensions\":[\"acc\"]},\"application/vnd.amiga.ami\":{\"source\":\"iana\",\"extensions\":[\"ami\"]},\"application/vnd.amundsen.maze+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.android.ota\":{\"source\":\"iana\"},\"application/vnd.android.package-archive\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"apk\"]},\"application/vnd.anki\":{\"source\":\"iana\"},\"application/vnd.anser-web-certificate-issue-initiation\":{\"source\":\"iana\",\"extensions\":[\"cii\"]},\"application/vnd.anser-web-funds-transfer-initiation\":{\"source\":\"apache\",\"extensions\":[\"fti\"]},\"application/vnd.antix.game-component\":{\"source\":\"iana\",\"extensions\":[\"atx\"]},\"application/vnd.apache.thrift.binary\":{\"source\":\"iana\"},\"application/vnd.apache.thrift.compact\":{\"source\":\"iana\"},\"application/vnd.apache.thrift.json\":{\"source\":\"iana\"},\"application/vnd.api+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.aplextor.warrp+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.apothekende.reservation+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.apple.installer+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"mpkg\"]},\"application/vnd.apple.keynote\":{\"source\":\"iana\",\"extensions\":[\"keynote\"]},\"application/vnd.apple.mpegurl\":{\"source\":\"iana\",\"extensions\":[\"m3u8\"]},\"application/vnd.apple.numbers\":{\"source\":\"iana\",\"extensions\":[\"numbers\"]},\"application/vnd.apple.pages\":{\"source\":\"iana\",\"extensions\":[\"pages\"]},\"application/vnd.apple.pkpass\":{\"compressible\":false,\"extensions\":[\"pkpass\"]},\"application/vnd.arastra.swi\":{\"source\":\"iana\"},\"application/vnd.aristanetworks.swi\":{\"source\":\"iana\",\"extensions\":[\"swi\"]},\"application/vnd.artisan+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.artsquare\":{\"source\":\"iana\"},\"application/vnd.astraea-software.iota\":{\"source\":\"iana\",\"extensions\":[\"iota\"]},\"application/vnd.audiograph\":{\"source\":\"iana\",\"extensions\":[\"aep\"]},\"application/vnd.autopackage\":{\"source\":\"iana\"},\"application/vnd.avalon+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.avistar+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.balsamiq.bmml+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"bmml\"]},\"application/vnd.balsamiq.bmpr\":{\"source\":\"iana\"},\"application/vnd.banana-accounting\":{\"source\":\"iana\"},\"application/vnd.bbf.usp.error\":{\"source\":\"iana\"},\"application/vnd.bbf.usp.msg\":{\"source\":\"iana\"},\"application/vnd.bbf.usp.msg+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.bekitzur-stech+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.bint.med-content\":{\"source\":\"iana\"},\"application/vnd.biopax.rdf+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.blink-idb-value-wrapper\":{\"source\":\"iana\"},\"application/vnd.blueice.multipass\":{\"source\":\"iana\",\"extensions\":[\"mpm\"]},\"application/vnd.bluetooth.ep.oob\":{\"source\":\"iana\"},\"application/vnd.bluetooth.le.oob\":{\"source\":\"iana\"},\"application/vnd.bmi\":{\"source\":\"iana\",\"extensions\":[\"bmi\"]},\"application/vnd.bpf\":{\"source\":\"iana\"},\"application/vnd.bpf3\":{\"source\":\"iana\"},\"application/vnd.businessobjects\":{\"source\":\"iana\",\"extensions\":[\"rep\"]},\"application/vnd.byu.uapi+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.cab-jscript\":{\"source\":\"iana\"},\"application/vnd.canon-cpdl\":{\"source\":\"iana\"},\"application/vnd.canon-lips\":{\"source\":\"iana\"},\"application/vnd.capasystems-pg+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.cendio.thinlinc.clientconf\":{\"source\":\"iana\"},\"application/vnd.century-systems.tcp_stream\":{\"source\":\"iana\"},\"application/vnd.chemdraw+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"cdxml\"]},\"application/vnd.chess-pgn\":{\"source\":\"iana\"},\"application/vnd.chipnuts.karaoke-mmd\":{\"source\":\"iana\",\"extensions\":[\"mmd\"]},\"application/vnd.ciedi\":{\"source\":\"iana\"},\"application/vnd.cinderella\":{\"source\":\"iana\",\"extensions\":[\"cdy\"]},\"application/vnd.cirpack.isdn-ext\":{\"source\":\"iana\"},\"application/vnd.citationstyles.style+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"csl\"]},\"application/vnd.claymore\":{\"source\":\"iana\",\"extensions\":[\"cla\"]},\"application/vnd.cloanto.rp9\":{\"source\":\"iana\",\"extensions\":[\"rp9\"]},\"application/vnd.clonk.c4group\":{\"source\":\"iana\",\"extensions\":[\"c4g\",\"c4d\",\"c4f\",\"c4p\",\"c4u\"]},\"application/vnd.cluetrust.cartomobile-config\":{\"source\":\"iana\",\"extensions\":[\"c11amc\"]},\"application/vnd.cluetrust.cartomobile-config-pkg\":{\"source\":\"iana\",\"extensions\":[\"c11amz\"]},\"application/vnd.coffeescript\":{\"source\":\"iana\"},\"application/vnd.collabio.xodocuments.document\":{\"source\":\"iana\"},\"application/vnd.collabio.xodocuments.document-template\":{\"source\":\"iana\"},\"application/vnd.collabio.xodocuments.presentation\":{\"source\":\"iana\"},\"application/vnd.collabio.xodocuments.presentation-template\":{\"source\":\"iana\"},\"application/vnd.collabio.xodocuments.spreadsheet\":{\"source\":\"iana\"},\"application/vnd.collabio.xodocuments.spreadsheet-template\":{\"source\":\"iana\"},\"application/vnd.collection+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.collection.doc+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.collection.next+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.comicbook+zip\":{\"source\":\"iana\",\"compressible\":false},\"application/vnd.comicbook-rar\":{\"source\":\"iana\"},\"application/vnd.commerce-battelle\":{\"source\":\"iana\"},\"application/vnd.commonspace\":{\"source\":\"iana\",\"extensions\":[\"csp\"]},\"application/vnd.contact.cmsg\":{\"source\":\"iana\",\"extensions\":[\"cdbcmsg\"]},\"application/vnd.coreos.ignition+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.cosmocaller\":{\"source\":\"iana\",\"extensions\":[\"cmc\"]},\"application/vnd.crick.clicker\":{\"source\":\"iana\",\"extensions\":[\"clkx\"]},\"application/vnd.crick.clicker.keyboard\":{\"source\":\"iana\",\"extensions\":[\"clkk\"]},\"application/vnd.crick.clicker.palette\":{\"source\":\"iana\",\"extensions\":[\"clkp\"]},\"application/vnd.crick.clicker.template\":{\"source\":\"iana\",\"extensions\":[\"clkt\"]},\"application/vnd.crick.clicker.wordbank\":{\"source\":\"iana\",\"extensions\":[\"clkw\"]},\"application/vnd.criticaltools.wbs+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"wbs\"]},\"application/vnd.cryptii.pipe+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.crypto-shade-file\":{\"source\":\"iana\"},\"application/vnd.ctc-posml\":{\"source\":\"iana\",\"extensions\":[\"pml\"]},\"application/vnd.ctct.ws+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.cups-pdf\":{\"source\":\"iana\"},\"application/vnd.cups-postscript\":{\"source\":\"iana\"},\"application/vnd.cups-ppd\":{\"source\":\"iana\",\"extensions\":[\"ppd\"]},\"application/vnd.cups-raster\":{\"source\":\"iana\"},\"application/vnd.cups-raw\":{\"source\":\"iana\"},\"application/vnd.curl\":{\"source\":\"iana\"},\"application/vnd.curl.car\":{\"source\":\"apache\",\"extensions\":[\"car\"]},\"application/vnd.curl.pcurl\":{\"source\":\"apache\",\"extensions\":[\"pcurl\"]},\"application/vnd.cyan.dean.root+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.cybank\":{\"source\":\"iana\"},\"application/vnd.d2l.coursepackage1p0+zip\":{\"source\":\"iana\",\"compressible\":false},\"application/vnd.dart\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"dart\"]},\"application/vnd.data-vision.rdz\":{\"source\":\"iana\",\"extensions\":[\"rdz\"]},\"application/vnd.datapackage+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.dataresource+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.dbf\":{\"source\":\"iana\"},\"application/vnd.debian.binary-package\":{\"source\":\"iana\"},\"application/vnd.dece.data\":{\"source\":\"iana\",\"extensions\":[\"uvf\",\"uvvf\",\"uvd\",\"uvvd\"]},\"application/vnd.dece.ttml+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"uvt\",\"uvvt\"]},\"application/vnd.dece.unspecified\":{\"source\":\"iana\",\"extensions\":[\"uvx\",\"uvvx\"]},\"application/vnd.dece.zip\":{\"source\":\"iana\",\"extensions\":[\"uvz\",\"uvvz\"]},\"application/vnd.denovo.fcselayout-link\":{\"source\":\"iana\",\"extensions\":[\"fe_launch\"]},\"application/vnd.desmume.movie\":{\"source\":\"iana\"},\"application/vnd.dir-bi.plate-dl-nosuffix\":{\"source\":\"iana\"},\"application/vnd.dm.delegation+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.dna\":{\"source\":\"iana\",\"extensions\":[\"dna\"]},\"application/vnd.document+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.dolby.mlp\":{\"source\":\"apache\",\"extensions\":[\"mlp\"]},\"application/vnd.dolby.mobile.1\":{\"source\":\"iana\"},\"application/vnd.dolby.mobile.2\":{\"source\":\"iana\"},\"application/vnd.doremir.scorecloud-binary-document\":{\"source\":\"iana\"},\"application/vnd.dpgraph\":{\"source\":\"iana\",\"extensions\":[\"dpg\"]},\"application/vnd.dreamfactory\":{\"source\":\"iana\",\"extensions\":[\"dfac\"]},\"application/vnd.drive+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.ds-keypoint\":{\"source\":\"apache\",\"extensions\":[\"kpxx\"]},\"application/vnd.dtg.local\":{\"source\":\"iana\"},\"application/vnd.dtg.local.flash\":{\"source\":\"iana\"},\"application/vnd.dtg.local.html\":{\"source\":\"iana\"},\"application/vnd.dvb.ait\":{\"source\":\"iana\",\"extensions\":[\"ait\"]},\"application/vnd.dvb.dvbisl+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.dvb.dvbj\":{\"source\":\"iana\"},\"application/vnd.dvb.esgcontainer\":{\"source\":\"iana\"},\"application/vnd.dvb.ipdcdftnotifaccess\":{\"source\":\"iana\"},\"application/vnd.dvb.ipdcesgaccess\":{\"source\":\"iana\"},\"application/vnd.dvb.ipdcesgaccess2\":{\"source\":\"iana\"},\"application/vnd.dvb.ipdcesgpdd\":{\"source\":\"iana\"},\"application/vnd.dvb.ipdcroaming\":{\"source\":\"iana\"},\"application/vnd.dvb.iptv.alfec-base\":{\"source\":\"iana\"},\"application/vnd.dvb.iptv.alfec-enhancement\":{\"source\":\"iana\"},\"application/vnd.dvb.notif-aggregate-root+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.dvb.notif-container+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.dvb.notif-generic+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.dvb.notif-ia-msglist+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.dvb.notif-ia-registration-request+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.dvb.notif-ia-registration-response+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.dvb.notif-init+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.dvb.pfr\":{\"source\":\"iana\"},\"application/vnd.dvb.service\":{\"source\":\"iana\",\"extensions\":[\"svc\"]},\"application/vnd.dxr\":{\"source\":\"iana\"},\"application/vnd.dynageo\":{\"source\":\"iana\",\"extensions\":[\"geo\"]},\"application/vnd.dzr\":{\"source\":\"iana\"},\"application/vnd.easykaraoke.cdgdownload\":{\"source\":\"iana\"},\"application/vnd.ecdis-update\":{\"source\":\"iana\"},\"application/vnd.ecip.rlp\":{\"source\":\"iana\"},\"application/vnd.ecowin.chart\":{\"source\":\"iana\",\"extensions\":[\"mag\"]},\"application/vnd.ecowin.filerequest\":{\"source\":\"iana\"},\"application/vnd.ecowin.fileupdate\":{\"source\":\"iana\"},\"application/vnd.ecowin.series\":{\"source\":\"iana\"},\"application/vnd.ecowin.seriesrequest\":{\"source\":\"iana\"},\"application/vnd.ecowin.seriesupdate\":{\"source\":\"iana\"},\"application/vnd.efi.img\":{\"source\":\"iana\"},\"application/vnd.efi.iso\":{\"source\":\"iana\"},\"application/vnd.emclient.accessrequest+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.enliven\":{\"source\":\"iana\",\"extensions\":[\"nml\"]},\"application/vnd.enphase.envoy\":{\"source\":\"iana\"},\"application/vnd.eprints.data+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.epson.esf\":{\"source\":\"iana\",\"extensions\":[\"esf\"]},\"application/vnd.epson.msf\":{\"source\":\"iana\",\"extensions\":[\"msf\"]},\"application/vnd.epson.quickanime\":{\"source\":\"iana\",\"extensions\":[\"qam\"]},\"application/vnd.epson.salt\":{\"source\":\"iana\",\"extensions\":[\"slt\"]},\"application/vnd.epson.ssf\":{\"source\":\"iana\",\"extensions\":[\"ssf\"]},\"application/vnd.ericsson.quickcall\":{\"source\":\"iana\"},\"application/vnd.espass-espass+zip\":{\"source\":\"iana\",\"compressible\":false},\"application/vnd.eszigno3+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"es3\",\"et3\"]},\"application/vnd.etsi.aoc+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.etsi.asic-e+zip\":{\"source\":\"iana\",\"compressible\":false},\"application/vnd.etsi.asic-s+zip\":{\"source\":\"iana\",\"compressible\":false},\"application/vnd.etsi.cug+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.etsi.iptvcommand+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.etsi.iptvdiscovery+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.etsi.iptvprofile+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.etsi.iptvsad-bc+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.etsi.iptvsad-cod+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.etsi.iptvsad-npvr+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.etsi.iptvservice+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.etsi.iptvsync+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.etsi.iptvueprofile+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.etsi.mcid+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.etsi.mheg5\":{\"source\":\"iana\"},\"application/vnd.etsi.overload-control-policy-dataset+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.etsi.pstn+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.etsi.sci+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.etsi.simservs+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.etsi.timestamp-token\":{\"source\":\"iana\"},\"application/vnd.etsi.tsl+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.etsi.tsl.der\":{\"source\":\"iana\"},\"application/vnd.eudora.data\":{\"source\":\"iana\"},\"application/vnd.evolv.ecig.profile\":{\"source\":\"iana\"},\"application/vnd.evolv.ecig.settings\":{\"source\":\"iana\"},\"application/vnd.evolv.ecig.theme\":{\"source\":\"iana\"},\"application/vnd.exstream-empower+zip\":{\"source\":\"iana\",\"compressible\":false},\"application/vnd.exstream-package\":{\"source\":\"iana\"},\"application/vnd.ezpix-album\":{\"source\":\"iana\",\"extensions\":[\"ez2\"]},\"application/vnd.ezpix-package\":{\"source\":\"iana\",\"extensions\":[\"ez3\"]},\"application/vnd.f-secure.mobile\":{\"source\":\"iana\"},\"application/vnd.fastcopy-disk-image\":{\"source\":\"iana\"},\"application/vnd.fdf\":{\"source\":\"iana\",\"extensions\":[\"fdf\"]},\"application/vnd.fdsn.mseed\":{\"source\":\"iana\",\"extensions\":[\"mseed\"]},\"application/vnd.fdsn.seed\":{\"source\":\"iana\",\"extensions\":[\"seed\",\"dataless\"]},\"application/vnd.ffsns\":{\"source\":\"iana\"},\"application/vnd.ficlab.flb+zip\":{\"source\":\"iana\",\"compressible\":false},\"application/vnd.filmit.zfc\":{\"source\":\"iana\"},\"application/vnd.fints\":{\"source\":\"iana\"},\"application/vnd.firemonkeys.cloudcell\":{\"source\":\"iana\"},\"application/vnd.flographit\":{\"source\":\"iana\",\"extensions\":[\"gph\"]},\"application/vnd.fluxtime.clip\":{\"source\":\"iana\",\"extensions\":[\"ftc\"]},\"application/vnd.font-fontforge-sfd\":{\"source\":\"iana\"},\"application/vnd.framemaker\":{\"source\":\"iana\",\"extensions\":[\"fm\",\"frame\",\"maker\",\"book\"]},\"application/vnd.frogans.fnc\":{\"source\":\"iana\",\"extensions\":[\"fnc\"]},\"application/vnd.frogans.ltf\":{\"source\":\"iana\",\"extensions\":[\"ltf\"]},\"application/vnd.fsc.weblaunch\":{\"source\":\"iana\",\"extensions\":[\"fsc\"]},\"application/vnd.fujitsu.oasys\":{\"source\":\"iana\",\"extensions\":[\"oas\"]},\"application/vnd.fujitsu.oasys2\":{\"source\":\"iana\",\"extensions\":[\"oa2\"]},\"application/vnd.fujitsu.oasys3\":{\"source\":\"iana\",\"extensions\":[\"oa3\"]},\"application/vnd.fujitsu.oasysgp\":{\"source\":\"iana\",\"extensions\":[\"fg5\"]},\"application/vnd.fujitsu.oasysprs\":{\"source\":\"iana\",\"extensions\":[\"bh2\"]},\"application/vnd.fujixerox.art-ex\":{\"source\":\"iana\"},\"application/vnd.fujixerox.art4\":{\"source\":\"iana\"},\"application/vnd.fujixerox.ddd\":{\"source\":\"iana\",\"extensions\":[\"ddd\"]},\"application/vnd.fujixerox.docuworks\":{\"source\":\"iana\",\"extensions\":[\"xdw\"]},\"application/vnd.fujixerox.docuworks.binder\":{\"source\":\"iana\",\"extensions\":[\"xbd\"]},\"application/vnd.fujixerox.docuworks.container\":{\"source\":\"iana\"},\"application/vnd.fujixerox.hbpl\":{\"source\":\"iana\"},\"application/vnd.fut-misnet\":{\"source\":\"iana\"},\"application/vnd.futoin+cbor\":{\"source\":\"iana\"},\"application/vnd.futoin+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.fuzzysheet\":{\"source\":\"iana\",\"extensions\":[\"fzs\"]},\"application/vnd.genomatix.tuxedo\":{\"source\":\"iana\",\"extensions\":[\"txd\"]},\"application/vnd.gentics.grd+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.geo+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.geocube+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.geogebra.file\":{\"source\":\"iana\",\"extensions\":[\"ggb\"]},\"application/vnd.geogebra.tool\":{\"source\":\"iana\",\"extensions\":[\"ggt\"]},\"application/vnd.geometry-explorer\":{\"source\":\"iana\",\"extensions\":[\"gex\",\"gre\"]},\"application/vnd.geonext\":{\"source\":\"iana\",\"extensions\":[\"gxt\"]},\"application/vnd.geoplan\":{\"source\":\"iana\",\"extensions\":[\"g2w\"]},\"application/vnd.geospace\":{\"source\":\"iana\",\"extensions\":[\"g3w\"]},\"application/vnd.gerber\":{\"source\":\"iana\"},\"application/vnd.globalplatform.card-content-mgt\":{\"source\":\"iana\"},\"application/vnd.globalplatform.card-content-mgt-response\":{\"source\":\"iana\"},\"application/vnd.gmx\":{\"source\":\"iana\",\"extensions\":[\"gmx\"]},\"application/vnd.google-apps.document\":{\"compressible\":false,\"extensions\":[\"gdoc\"]},\"application/vnd.google-apps.presentation\":{\"compressible\":false,\"extensions\":[\"gslides\"]},\"application/vnd.google-apps.spreadsheet\":{\"compressible\":false,\"extensions\":[\"gsheet\"]},\"application/vnd.google-earth.kml+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"kml\"]},\"application/vnd.google-earth.kmz\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"kmz\"]},\"application/vnd.gov.sk.e-form+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.gov.sk.e-form+zip\":{\"source\":\"iana\",\"compressible\":false},\"application/vnd.gov.sk.xmldatacontainer+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.grafeq\":{\"source\":\"iana\",\"extensions\":[\"gqf\",\"gqs\"]},\"application/vnd.gridmp\":{\"source\":\"iana\"},\"application/vnd.groove-account\":{\"source\":\"iana\",\"extensions\":[\"gac\"]},\"application/vnd.groove-help\":{\"source\":\"iana\",\"extensions\":[\"ghf\"]},\"application/vnd.groove-identity-message\":{\"source\":\"iana\",\"extensions\":[\"gim\"]},\"application/vnd.groove-injector\":{\"source\":\"iana\",\"extensions\":[\"grv\"]},\"application/vnd.groove-tool-message\":{\"source\":\"iana\",\"extensions\":[\"gtm\"]},\"application/vnd.groove-tool-template\":{\"source\":\"iana\",\"extensions\":[\"tpl\"]},\"application/vnd.groove-vcard\":{\"source\":\"iana\",\"extensions\":[\"vcg\"]},\"application/vnd.hal+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.hal+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"hal\"]},\"application/vnd.handheld-entertainment+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"zmm\"]},\"application/vnd.hbci\":{\"source\":\"iana\",\"extensions\":[\"hbci\"]},\"application/vnd.hc+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.hcl-bireports\":{\"source\":\"iana\"},\"application/vnd.hdt\":{\"source\":\"iana\"},\"application/vnd.heroku+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.hhe.lesson-player\":{\"source\":\"iana\",\"extensions\":[\"les\"]},\"application/vnd.hp-hpgl\":{\"source\":\"iana\",\"extensions\":[\"hpgl\"]},\"application/vnd.hp-hpid\":{\"source\":\"iana\",\"extensions\":[\"hpid\"]},\"application/vnd.hp-hps\":{\"source\":\"iana\",\"extensions\":[\"hps\"]},\"application/vnd.hp-jlyt\":{\"source\":\"iana\",\"extensions\":[\"jlt\"]},\"application/vnd.hp-pcl\":{\"source\":\"iana\",\"extensions\":[\"pcl\"]},\"application/vnd.hp-pclxl\":{\"source\":\"iana\",\"extensions\":[\"pclxl\"]},\"application/vnd.httphone\":{\"source\":\"iana\"},\"application/vnd.hydrostatix.sof-data\":{\"source\":\"iana\",\"extensions\":[\"sfd-hdstx\"]},\"application/vnd.hyper+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.hyper-item+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.hyperdrive+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.hzn-3d-crossword\":{\"source\":\"iana\"},\"application/vnd.ibm.afplinedata\":{\"source\":\"iana\"},\"application/vnd.ibm.electronic-media\":{\"source\":\"iana\"},\"application/vnd.ibm.minipay\":{\"source\":\"iana\",\"extensions\":[\"mpy\"]},\"application/vnd.ibm.modcap\":{\"source\":\"iana\",\"extensions\":[\"afp\",\"listafp\",\"list3820\"]},\"application/vnd.ibm.rights-management\":{\"source\":\"iana\",\"extensions\":[\"irm\"]},\"application/vnd.ibm.secure-container\":{\"source\":\"iana\",\"extensions\":[\"sc\"]},\"application/vnd.iccprofile\":{\"source\":\"iana\",\"extensions\":[\"icc\",\"icm\"]},\"application/vnd.ieee.1905\":{\"source\":\"iana\"},\"application/vnd.igloader\":{\"source\":\"iana\",\"extensions\":[\"igl\"]},\"application/vnd.imagemeter.folder+zip\":{\"source\":\"iana\",\"compressible\":false},\"application/vnd.imagemeter.image+zip\":{\"source\":\"iana\",\"compressible\":false},\"application/vnd.immervision-ivp\":{\"source\":\"iana\",\"extensions\":[\"ivp\"]},\"application/vnd.immervision-ivu\":{\"source\":\"iana\",\"extensions\":[\"ivu\"]},\"application/vnd.ims.imsccv1p1\":{\"source\":\"iana\"},\"application/vnd.ims.imsccv1p2\":{\"source\":\"iana\"},\"application/vnd.ims.imsccv1p3\":{\"source\":\"iana\"},\"application/vnd.ims.lis.v2.result+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.ims.lti.v2.toolconsumerprofile+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.ims.lti.v2.toolproxy+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.ims.lti.v2.toolproxy.id+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.ims.lti.v2.toolsettings+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.ims.lti.v2.toolsettings.simple+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.informedcontrol.rms+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.informix-visionary\":{\"source\":\"iana\"},\"application/vnd.infotech.project\":{\"source\":\"iana\"},\"application/vnd.infotech.project+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.innopath.wamp.notification\":{\"source\":\"iana\"},\"application/vnd.insors.igm\":{\"source\":\"iana\",\"extensions\":[\"igm\"]},\"application/vnd.intercon.formnet\":{\"source\":\"iana\",\"extensions\":[\"xpw\",\"xpx\"]},\"application/vnd.intergeo\":{\"source\":\"iana\",\"extensions\":[\"i2g\"]},\"application/vnd.intertrust.digibox\":{\"source\":\"iana\"},\"application/vnd.intertrust.nncp\":{\"source\":\"iana\"},\"application/vnd.intu.qbo\":{\"source\":\"iana\",\"extensions\":[\"qbo\"]},\"application/vnd.intu.qfx\":{\"source\":\"iana\",\"extensions\":[\"qfx\"]},\"application/vnd.iptc.g2.catalogitem+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.iptc.g2.conceptitem+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.iptc.g2.knowledgeitem+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.iptc.g2.newsitem+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.iptc.g2.newsmessage+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.iptc.g2.packageitem+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.iptc.g2.planningitem+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.ipunplugged.rcprofile\":{\"source\":\"iana\",\"extensions\":[\"rcprofile\"]},\"application/vnd.irepository.package+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"irp\"]},\"application/vnd.is-xpr\":{\"source\":\"iana\",\"extensions\":[\"xpr\"]},\"application/vnd.isac.fcs\":{\"source\":\"iana\",\"extensions\":[\"fcs\"]},\"application/vnd.iso11783-10+zip\":{\"source\":\"iana\",\"compressible\":false},\"application/vnd.jam\":{\"source\":\"iana\",\"extensions\":[\"jam\"]},\"application/vnd.japannet-directory-service\":{\"source\":\"iana\"},\"application/vnd.japannet-jpnstore-wakeup\":{\"source\":\"iana\"},\"application/vnd.japannet-payment-wakeup\":{\"source\":\"iana\"},\"application/vnd.japannet-registration\":{\"source\":\"iana\"},\"application/vnd.japannet-registration-wakeup\":{\"source\":\"iana\"},\"application/vnd.japannet-setstore-wakeup\":{\"source\":\"iana\"},\"application/vnd.japannet-verification\":{\"source\":\"iana\"},\"application/vnd.japannet-verification-wakeup\":{\"source\":\"iana\"},\"application/vnd.jcp.javame.midlet-rms\":{\"source\":\"iana\",\"extensions\":[\"rms\"]},\"application/vnd.jisp\":{\"source\":\"iana\",\"extensions\":[\"jisp\"]},\"application/vnd.joost.joda-archive\":{\"source\":\"iana\",\"extensions\":[\"joda\"]},\"application/vnd.jsk.isdn-ngn\":{\"source\":\"iana\"},\"application/vnd.kahootz\":{\"source\":\"iana\",\"extensions\":[\"ktz\",\"ktr\"]},\"application/vnd.kde.karbon\":{\"source\":\"iana\",\"extensions\":[\"karbon\"]},\"application/vnd.kde.kchart\":{\"source\":\"iana\",\"extensions\":[\"chrt\"]},\"application/vnd.kde.kformula\":{\"source\":\"iana\",\"extensions\":[\"kfo\"]},\"application/vnd.kde.kivio\":{\"source\":\"iana\",\"extensions\":[\"flw\"]},\"application/vnd.kde.kontour\":{\"source\":\"iana\",\"extensions\":[\"kon\"]},\"application/vnd.kde.kpresenter\":{\"source\":\"iana\",\"extensions\":[\"kpr\",\"kpt\"]},\"application/vnd.kde.kspread\":{\"source\":\"iana\",\"extensions\":[\"ksp\"]},\"application/vnd.kde.kword\":{\"source\":\"iana\",\"extensions\":[\"kwd\",\"kwt\"]},\"application/vnd.kenameaapp\":{\"source\":\"iana\",\"extensions\":[\"htke\"]},\"application/vnd.kidspiration\":{\"source\":\"iana\",\"extensions\":[\"kia\"]},\"application/vnd.kinar\":{\"source\":\"iana\",\"extensions\":[\"kne\",\"knp\"]},\"application/vnd.koan\":{\"source\":\"iana\",\"extensions\":[\"skp\",\"skd\",\"skt\",\"skm\"]},\"application/vnd.kodak-descriptor\":{\"source\":\"iana\",\"extensions\":[\"sse\"]},\"application/vnd.las\":{\"source\":\"iana\"},\"application/vnd.las.las+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.las.las+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"lasxml\"]},\"application/vnd.laszip\":{\"source\":\"iana\"},\"application/vnd.leap+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.liberty-request+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.llamagraphics.life-balance.desktop\":{\"source\":\"iana\",\"extensions\":[\"lbd\"]},\"application/vnd.llamagraphics.life-balance.exchange+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"lbe\"]},\"application/vnd.logipipe.circuit+zip\":{\"source\":\"iana\",\"compressible\":false},\"application/vnd.loom\":{\"source\":\"iana\"},\"application/vnd.lotus-1-2-3\":{\"source\":\"iana\",\"extensions\":[\"123\"]},\"application/vnd.lotus-approach\":{\"source\":\"iana\",\"extensions\":[\"apr\"]},\"application/vnd.lotus-freelance\":{\"source\":\"iana\",\"extensions\":[\"pre\"]},\"application/vnd.lotus-notes\":{\"source\":\"iana\",\"extensions\":[\"nsf\"]},\"application/vnd.lotus-organizer\":{\"source\":\"iana\",\"extensions\":[\"org\"]},\"application/vnd.lotus-screencam\":{\"source\":\"iana\",\"extensions\":[\"scm\"]},\"application/vnd.lotus-wordpro\":{\"source\":\"iana\",\"extensions\":[\"lwp\"]},\"application/vnd.macports.portpkg\":{\"source\":\"iana\",\"extensions\":[\"portpkg\"]},\"application/vnd.mapbox-vector-tile\":{\"source\":\"iana\"},\"application/vnd.marlin.drm.actiontoken+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.marlin.drm.conftoken+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.marlin.drm.license+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.marlin.drm.mdcf\":{\"source\":\"iana\"},\"application/vnd.mason+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.maxmind.maxmind-db\":{\"source\":\"iana\"},\"application/vnd.mcd\":{\"source\":\"iana\",\"extensions\":[\"mcd\"]},\"application/vnd.medcalcdata\":{\"source\":\"iana\",\"extensions\":[\"mc1\"]},\"application/vnd.mediastation.cdkey\":{\"source\":\"iana\",\"extensions\":[\"cdkey\"]},\"application/vnd.meridian-slingshot\":{\"source\":\"iana\"},\"application/vnd.mfer\":{\"source\":\"iana\",\"extensions\":[\"mwf\"]},\"application/vnd.mfmp\":{\"source\":\"iana\",\"extensions\":[\"mfm\"]},\"application/vnd.micro+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.micrografx.flo\":{\"source\":\"iana\",\"extensions\":[\"flo\"]},\"application/vnd.micrografx.igx\":{\"source\":\"iana\",\"extensions\":[\"igx\"]},\"application/vnd.microsoft.portable-executable\":{\"source\":\"iana\"},\"application/vnd.microsoft.windows.thumbnail-cache\":{\"source\":\"iana\"},\"application/vnd.miele+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.mif\":{\"source\":\"iana\",\"extensions\":[\"mif\"]},\"application/vnd.minisoft-hp3000-save\":{\"source\":\"iana\"},\"application/vnd.mitsubishi.misty-guard.trustweb\":{\"source\":\"iana\"},\"application/vnd.mobius.daf\":{\"source\":\"iana\",\"extensions\":[\"daf\"]},\"application/vnd.mobius.dis\":{\"source\":\"iana\",\"extensions\":[\"dis\"]},\"application/vnd.mobius.mbk\":{\"source\":\"iana\",\"extensions\":[\"mbk\"]},\"application/vnd.mobius.mqy\":{\"source\":\"iana\",\"extensions\":[\"mqy\"]},\"application/vnd.mobius.msl\":{\"source\":\"iana\",\"extensions\":[\"msl\"]},\"application/vnd.mobius.plc\":{\"source\":\"iana\",\"extensions\":[\"plc\"]},\"application/vnd.mobius.txf\":{\"source\":\"iana\",\"extensions\":[\"txf\"]},\"application/vnd.mophun.application\":{\"source\":\"iana\",\"extensions\":[\"mpn\"]},\"application/vnd.mophun.certificate\":{\"source\":\"iana\",\"extensions\":[\"mpc\"]},\"application/vnd.motorola.flexsuite\":{\"source\":\"iana\"},\"application/vnd.motorola.flexsuite.adsi\":{\"source\":\"iana\"},\"application/vnd.motorola.flexsuite.fis\":{\"source\":\"iana\"},\"application/vnd.motorola.flexsuite.gotap\":{\"source\":\"iana\"},\"application/vnd.motorola.flexsuite.kmr\":{\"source\":\"iana\"},\"application/vnd.motorola.flexsuite.ttc\":{\"source\":\"iana\"},\"application/vnd.motorola.flexsuite.wem\":{\"source\":\"iana\"},\"application/vnd.motorola.iprm\":{\"source\":\"iana\"},\"application/vnd.mozilla.xul+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"xul\"]},\"application/vnd.ms-3mfdocument\":{\"source\":\"iana\"},\"application/vnd.ms-artgalry\":{\"source\":\"iana\",\"extensions\":[\"cil\"]},\"application/vnd.ms-asf\":{\"source\":\"iana\"},\"application/vnd.ms-cab-compressed\":{\"source\":\"iana\",\"extensions\":[\"cab\"]},\"application/vnd.ms-color.iccprofile\":{\"source\":\"apache\"},\"application/vnd.ms-excel\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"xls\",\"xlm\",\"xla\",\"xlc\",\"xlt\",\"xlw\"]},\"application/vnd.ms-excel.addin.macroenabled.12\":{\"source\":\"iana\",\"extensions\":[\"xlam\"]},\"application/vnd.ms-excel.sheet.binary.macroenabled.12\":{\"source\":\"iana\",\"extensions\":[\"xlsb\"]},\"application/vnd.ms-excel.sheet.macroenabled.12\":{\"source\":\"iana\",\"extensions\":[\"xlsm\"]},\"application/vnd.ms-excel.template.macroenabled.12\":{\"source\":\"iana\",\"extensions\":[\"xltm\"]},\"application/vnd.ms-fontobject\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"eot\"]},\"application/vnd.ms-htmlhelp\":{\"source\":\"iana\",\"extensions\":[\"chm\"]},\"application/vnd.ms-ims\":{\"source\":\"iana\",\"extensions\":[\"ims\"]},\"application/vnd.ms-lrm\":{\"source\":\"iana\",\"extensions\":[\"lrm\"]},\"application/vnd.ms-office.activex+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.ms-officetheme\":{\"source\":\"iana\",\"extensions\":[\"thmx\"]},\"application/vnd.ms-opentype\":{\"source\":\"apache\",\"compressible\":true},\"application/vnd.ms-outlook\":{\"compressible\":false,\"extensions\":[\"msg\"]},\"application/vnd.ms-package.obfuscated-opentype\":{\"source\":\"apache\"},\"application/vnd.ms-pki.seccat\":{\"source\":\"apache\",\"extensions\":[\"cat\"]},\"application/vnd.ms-pki.stl\":{\"source\":\"apache\",\"extensions\":[\"stl\"]},\"application/vnd.ms-playready.initiator+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.ms-powerpoint\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"ppt\",\"pps\",\"pot\"]},\"application/vnd.ms-powerpoint.addin.macroenabled.12\":{\"source\":\"iana\",\"extensions\":[\"ppam\"]},\"application/vnd.ms-powerpoint.presentation.macroenabled.12\":{\"source\":\"iana\",\"extensions\":[\"pptm\"]},\"application/vnd.ms-powerpoint.slide.macroenabled.12\":{\"source\":\"iana\",\"extensions\":[\"sldm\"]},\"application/vnd.ms-powerpoint.slideshow.macroenabled.12\":{\"source\":\"iana\",\"extensions\":[\"ppsm\"]},\"application/vnd.ms-powerpoint.template.macroenabled.12\":{\"source\":\"iana\",\"extensions\":[\"potm\"]},\"application/vnd.ms-printdevicecapabilities+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.ms-printing.printticket+xml\":{\"source\":\"apache\",\"compressible\":true},\"application/vnd.ms-printschematicket+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.ms-project\":{\"source\":\"iana\",\"extensions\":[\"mpp\",\"mpt\"]},\"application/vnd.ms-tnef\":{\"source\":\"iana\"},\"application/vnd.ms-windows.devicepairing\":{\"source\":\"iana\"},\"application/vnd.ms-windows.nwprinting.oob\":{\"source\":\"iana\"},\"application/vnd.ms-windows.printerpairing\":{\"source\":\"iana\"},\"application/vnd.ms-windows.wsd.oob\":{\"source\":\"iana\"},\"application/vnd.ms-wmdrm.lic-chlg-req\":{\"source\":\"iana\"},\"application/vnd.ms-wmdrm.lic-resp\":{\"source\":\"iana\"},\"application/vnd.ms-wmdrm.meter-chlg-req\":{\"source\":\"iana\"},\"application/vnd.ms-wmdrm.meter-resp\":{\"source\":\"iana\"},\"application/vnd.ms-word.document.macroenabled.12\":{\"source\":\"iana\",\"extensions\":[\"docm\"]},\"application/vnd.ms-word.template.macroenabled.12\":{\"source\":\"iana\",\"extensions\":[\"dotm\"]},\"application/vnd.ms-works\":{\"source\":\"iana\",\"extensions\":[\"wps\",\"wks\",\"wcm\",\"wdb\"]},\"application/vnd.ms-wpl\":{\"source\":\"iana\",\"extensions\":[\"wpl\"]},\"application/vnd.ms-xpsdocument\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"xps\"]},\"application/vnd.msa-disk-image\":{\"source\":\"iana\"},\"application/vnd.mseq\":{\"source\":\"iana\",\"extensions\":[\"mseq\"]},\"application/vnd.msign\":{\"source\":\"iana\"},\"application/vnd.multiad.creator\":{\"source\":\"iana\"},\"application/vnd.multiad.creator.cif\":{\"source\":\"iana\"},\"application/vnd.music-niff\":{\"source\":\"iana\"},\"application/vnd.musician\":{\"source\":\"iana\",\"extensions\":[\"mus\"]},\"application/vnd.muvee.style\":{\"source\":\"iana\",\"extensions\":[\"msty\"]},\"application/vnd.mynfc\":{\"source\":\"iana\",\"extensions\":[\"taglet\"]},\"application/vnd.ncd.control\":{\"source\":\"iana\"},\"application/vnd.ncd.reference\":{\"source\":\"iana\"},\"application/vnd.nearst.inv+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.nervana\":{\"source\":\"iana\"},\"application/vnd.netfpx\":{\"source\":\"iana\"},\"application/vnd.neurolanguage.nlu\":{\"source\":\"iana\",\"extensions\":[\"nlu\"]},\"application/vnd.nimn\":{\"source\":\"iana\"},\"application/vnd.nintendo.nitro.rom\":{\"source\":\"iana\"},\"application/vnd.nintendo.snes.rom\":{\"source\":\"iana\"},\"application/vnd.nitf\":{\"source\":\"iana\",\"extensions\":[\"ntf\",\"nitf\"]},\"application/vnd.noblenet-directory\":{\"source\":\"iana\",\"extensions\":[\"nnd\"]},\"application/vnd.noblenet-sealer\":{\"source\":\"iana\",\"extensions\":[\"nns\"]},\"application/vnd.noblenet-web\":{\"source\":\"iana\",\"extensions\":[\"nnw\"]},\"application/vnd.nokia.catalogs\":{\"source\":\"iana\"},\"application/vnd.nokia.conml+wbxml\":{\"source\":\"iana\"},\"application/vnd.nokia.conml+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.nokia.iptv.config+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.nokia.isds-radio-presets\":{\"source\":\"iana\"},\"application/vnd.nokia.landmark+wbxml\":{\"source\":\"iana\"},\"application/vnd.nokia.landmark+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.nokia.landmarkcollection+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.nokia.n-gage.ac+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"ac\"]},\"application/vnd.nokia.n-gage.data\":{\"source\":\"iana\",\"extensions\":[\"ngdat\"]},\"application/vnd.nokia.n-gage.symbian.install\":{\"source\":\"iana\",\"extensions\":[\"n-gage\"]},\"application/vnd.nokia.ncd\":{\"source\":\"iana\"},\"application/vnd.nokia.pcd+wbxml\":{\"source\":\"iana\"},\"application/vnd.nokia.pcd+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.nokia.radio-preset\":{\"source\":\"iana\",\"extensions\":[\"rpst\"]},\"application/vnd.nokia.radio-presets\":{\"source\":\"iana\",\"extensions\":[\"rpss\"]},\"application/vnd.novadigm.edm\":{\"source\":\"iana\",\"extensions\":[\"edm\"]},\"application/vnd.novadigm.edx\":{\"source\":\"iana\",\"extensions\":[\"edx\"]},\"application/vnd.novadigm.ext\":{\"source\":\"iana\",\"extensions\":[\"ext\"]},\"application/vnd.ntt-local.content-share\":{\"source\":\"iana\"},\"application/vnd.ntt-local.file-transfer\":{\"source\":\"iana\"},\"application/vnd.ntt-local.ogw_remote-access\":{\"source\":\"iana\"},\"application/vnd.ntt-local.sip-ta_remote\":{\"source\":\"iana\"},\"application/vnd.ntt-local.sip-ta_tcp_stream\":{\"source\":\"iana\"},\"application/vnd.oasis.opendocument.chart\":{\"source\":\"iana\",\"extensions\":[\"odc\"]},\"application/vnd.oasis.opendocument.chart-template\":{\"source\":\"iana\",\"extensions\":[\"otc\"]},\"application/vnd.oasis.opendocument.database\":{\"source\":\"iana\",\"extensions\":[\"odb\"]},\"application/vnd.oasis.opendocument.formula\":{\"source\":\"iana\",\"extensions\":[\"odf\"]},\"application/vnd.oasis.opendocument.formula-template\":{\"source\":\"iana\",\"extensions\":[\"odft\"]},\"application/vnd.oasis.opendocument.graphics\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"odg\"]},\"application/vnd.oasis.opendocument.graphics-template\":{\"source\":\"iana\",\"extensions\":[\"otg\"]},\"application/vnd.oasis.opendocument.image\":{\"source\":\"iana\",\"extensions\":[\"odi\"]},\"application/vnd.oasis.opendocument.image-template\":{\"source\":\"iana\",\"extensions\":[\"oti\"]},\"application/vnd.oasis.opendocument.presentation\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"odp\"]},\"application/vnd.oasis.opendocument.presentation-template\":{\"source\":\"iana\",\"extensions\":[\"otp\"]},\"application/vnd.oasis.opendocument.spreadsheet\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"ods\"]},\"application/vnd.oasis.opendocument.spreadsheet-template\":{\"source\":\"iana\",\"extensions\":[\"ots\"]},\"application/vnd.oasis.opendocument.text\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"odt\"]},\"application/vnd.oasis.opendocument.text-master\":{\"source\":\"iana\",\"extensions\":[\"odm\"]},\"application/vnd.oasis.opendocument.text-template\":{\"source\":\"iana\",\"extensions\":[\"ott\"]},\"application/vnd.oasis.opendocument.text-web\":{\"source\":\"iana\",\"extensions\":[\"oth\"]},\"application/vnd.obn\":{\"source\":\"iana\"},\"application/vnd.ocf+cbor\":{\"source\":\"iana\"},\"application/vnd.oci.image.manifest.v1+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oftn.l10n+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oipf.contentaccessdownload+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oipf.contentaccessstreaming+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oipf.cspg-hexbinary\":{\"source\":\"iana\"},\"application/vnd.oipf.dae.svg+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oipf.dae.xhtml+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oipf.mippvcontrolmessage+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oipf.pae.gem\":{\"source\":\"iana\"},\"application/vnd.oipf.spdiscovery+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oipf.spdlist+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oipf.ueprofile+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oipf.userprofile+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.olpc-sugar\":{\"source\":\"iana\",\"extensions\":[\"xo\"]},\"application/vnd.oma-scws-config\":{\"source\":\"iana\"},\"application/vnd.oma-scws-http-request\":{\"source\":\"iana\"},\"application/vnd.oma-scws-http-response\":{\"source\":\"iana\"},\"application/vnd.oma.bcast.associated-procedure-parameter+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.bcast.drm-trigger+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.bcast.imd+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.bcast.ltkm\":{\"source\":\"iana\"},\"application/vnd.oma.bcast.notification+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.bcast.provisioningtrigger\":{\"source\":\"iana\"},\"application/vnd.oma.bcast.sgboot\":{\"source\":\"iana\"},\"application/vnd.oma.bcast.sgdd+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.bcast.sgdu\":{\"source\":\"iana\"},\"application/vnd.oma.bcast.simple-symbol-container\":{\"source\":\"iana\"},\"application/vnd.oma.bcast.smartcard-trigger+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.bcast.sprov+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.bcast.stkm\":{\"source\":\"iana\"},\"application/vnd.oma.cab-address-book+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.cab-feature-handler+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.cab-pcc+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.cab-subs-invite+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.cab-user-prefs+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.dcd\":{\"source\":\"iana\"},\"application/vnd.oma.dcdc\":{\"source\":\"iana\"},\"application/vnd.oma.dd2+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"dd2\"]},\"application/vnd.oma.drm.risd+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.group-usage-list+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.lwm2m+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.lwm2m+tlv\":{\"source\":\"iana\"},\"application/vnd.oma.pal+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.poc.detailed-progress-report+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.poc.final-report+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.poc.groups+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.poc.invocation-descriptor+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.poc.optimized-progress-report+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.push\":{\"source\":\"iana\"},\"application/vnd.oma.scidm.messages+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oma.xcap-directory+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.omads-email+xml\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true},\"application/vnd.omads-file+xml\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true},\"application/vnd.omads-folder+xml\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true},\"application/vnd.omaloc-supl-init\":{\"source\":\"iana\"},\"application/vnd.onepager\":{\"source\":\"iana\"},\"application/vnd.onepagertamp\":{\"source\":\"iana\"},\"application/vnd.onepagertamx\":{\"source\":\"iana\"},\"application/vnd.onepagertat\":{\"source\":\"iana\"},\"application/vnd.onepagertatp\":{\"source\":\"iana\"},\"application/vnd.onepagertatx\":{\"source\":\"iana\"},\"application/vnd.openblox.game+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"obgx\"]},\"application/vnd.openblox.game-binary\":{\"source\":\"iana\"},\"application/vnd.openeye.oeb\":{\"source\":\"iana\"},\"application/vnd.openofficeorg.extension\":{\"source\":\"apache\",\"extensions\":[\"oxt\"]},\"application/vnd.openstreetmap.data+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"osm\"]},\"application/vnd.openxmlformats-officedocument.custom-properties+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.customxmlproperties+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.drawing+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.drawingml.chart+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.drawingml.diagramcolors+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.drawingml.diagramdata+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.drawingml.diagramlayout+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.drawingml.diagramstyle+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.extended-properties+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.presentationml.commentauthors+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.presentationml.comments+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.presentationml.handoutmaster+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.presentationml.notesmaster+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.presentationml.notesslide+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.presentationml.presentation\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"pptx\"]},\"application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.presentationml.presprops+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.presentationml.slide\":{\"source\":\"iana\",\"extensions\":[\"sldx\"]},\"application/vnd.openxmlformats-officedocument.presentationml.slide+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.presentationml.slidelayout+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.presentationml.slidemaster+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.presentationml.slideshow\":{\"source\":\"iana\",\"extensions\":[\"ppsx\"]},\"application/vnd.openxmlformats-officedocument.presentationml.slideshow.main+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.presentationml.slideupdateinfo+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.presentationml.tablestyles+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.presentationml.tags+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.presentationml.template\":{\"source\":\"iana\",\"extensions\":[\"potx\"]},\"application/vnd.openxmlformats-officedocument.presentationml.template.main+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.presentationml.viewprops+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.calcchain+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.connections+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.dialogsheet+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.externallink+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.pivotcachedefinition+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.pivotcacherecords+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.pivottable+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.querytable+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.revisionheaders+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.revisionlog+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.sharedstrings+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"xlsx\"]},\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheetmetadata+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.tablesinglecells+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.template\":{\"source\":\"iana\",\"extensions\":[\"xltx\"]},\"application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.usernames+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.volatiledependencies+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.theme+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.themeoverride+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.vmldrawing\":{\"source\":\"iana\"},\"application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.wordprocessingml.document\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"docx\"]},\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.wordprocessingml.fonttable+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.wordprocessingml.template\":{\"source\":\"iana\",\"extensions\":[\"dotx\"]},\"application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-officedocument.wordprocessingml.websettings+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-package.core-properties+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-package.digital-signature-xmlsignature+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.openxmlformats-package.relationships+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oracle.resource+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.orange.indata\":{\"source\":\"iana\"},\"application/vnd.osa.netdeploy\":{\"source\":\"iana\"},\"application/vnd.osgeo.mapguide.package\":{\"source\":\"iana\",\"extensions\":[\"mgp\"]},\"application/vnd.osgi.bundle\":{\"source\":\"iana\"},\"application/vnd.osgi.dp\":{\"source\":\"iana\",\"extensions\":[\"dp\"]},\"application/vnd.osgi.subsystem\":{\"source\":\"iana\",\"extensions\":[\"esa\"]},\"application/vnd.otps.ct-kip+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.oxli.countgraph\":{\"source\":\"iana\"},\"application/vnd.pagerduty+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.palm\":{\"source\":\"iana\",\"extensions\":[\"pdb\",\"pqa\",\"oprc\"]},\"application/vnd.panoply\":{\"source\":\"iana\"},\"application/vnd.paos.xml\":{\"source\":\"iana\"},\"application/vnd.patentdive\":{\"source\":\"iana\"},\"application/vnd.patientecommsdoc\":{\"source\":\"iana\"},\"application/vnd.pawaafile\":{\"source\":\"iana\",\"extensions\":[\"paw\"]},\"application/vnd.pcos\":{\"source\":\"iana\"},\"application/vnd.pg.format\":{\"source\":\"iana\",\"extensions\":[\"str\"]},\"application/vnd.pg.osasli\":{\"source\":\"iana\",\"extensions\":[\"ei6\"]},\"application/vnd.piaccess.application-licence\":{\"source\":\"iana\"},\"application/vnd.picsel\":{\"source\":\"iana\",\"extensions\":[\"efif\"]},\"application/vnd.pmi.widget\":{\"source\":\"iana\",\"extensions\":[\"wg\"]},\"application/vnd.poc.group-advertisement+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.pocketlearn\":{\"source\":\"iana\",\"extensions\":[\"plf\"]},\"application/vnd.powerbuilder6\":{\"source\":\"iana\",\"extensions\":[\"pbd\"]},\"application/vnd.powerbuilder6-s\":{\"source\":\"iana\"},\"application/vnd.powerbuilder7\":{\"source\":\"iana\"},\"application/vnd.powerbuilder7-s\":{\"source\":\"iana\"},\"application/vnd.powerbuilder75\":{\"source\":\"iana\"},\"application/vnd.powerbuilder75-s\":{\"source\":\"iana\"},\"application/vnd.preminet\":{\"source\":\"iana\"},\"application/vnd.previewsystems.box\":{\"source\":\"iana\",\"extensions\":[\"box\"]},\"application/vnd.proteus.magazine\":{\"source\":\"iana\",\"extensions\":[\"mgz\"]},\"application/vnd.psfs\":{\"source\":\"iana\"},\"application/vnd.publishare-delta-tree\":{\"source\":\"iana\",\"extensions\":[\"qps\"]},\"application/vnd.pvi.ptid1\":{\"source\":\"iana\",\"extensions\":[\"ptid\"]},\"application/vnd.pwg-multiplexed\":{\"source\":\"iana\"},\"application/vnd.pwg-xhtml-print+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.qualcomm.brew-app-res\":{\"source\":\"iana\"},\"application/vnd.quarantainenet\":{\"source\":\"iana\"},\"application/vnd.quark.quarkxpress\":{\"source\":\"iana\",\"extensions\":[\"qxd\",\"qxt\",\"qwd\",\"qwt\",\"qxl\",\"qxb\"]},\"application/vnd.quobject-quoxdocument\":{\"source\":\"iana\"},\"application/vnd.radisys.moml+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.radisys.msml+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.radisys.msml-audit+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.radisys.msml-audit-conf+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.radisys.msml-audit-conn+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.radisys.msml-audit-dialog+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.radisys.msml-audit-stream+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.radisys.msml-conf+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.radisys.msml-dialog+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.radisys.msml-dialog-base+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.radisys.msml-dialog-fax-detect+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.radisys.msml-dialog-fax-sendrecv+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.radisys.msml-dialog-group+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.radisys.msml-dialog-speech+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.radisys.msml-dialog-transform+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.rainstor.data\":{\"source\":\"iana\"},\"application/vnd.rapid\":{\"source\":\"iana\"},\"application/vnd.rar\":{\"source\":\"iana\"},\"application/vnd.realvnc.bed\":{\"source\":\"iana\",\"extensions\":[\"bed\"]},\"application/vnd.recordare.musicxml\":{\"source\":\"iana\",\"extensions\":[\"mxl\"]},\"application/vnd.recordare.musicxml+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"musicxml\"]},\"application/vnd.renlearn.rlprint\":{\"source\":\"iana\"},\"application/vnd.restful+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.rig.cryptonote\":{\"source\":\"iana\",\"extensions\":[\"cryptonote\"]},\"application/vnd.rim.cod\":{\"source\":\"apache\",\"extensions\":[\"cod\"]},\"application/vnd.rn-realmedia\":{\"source\":\"apache\",\"extensions\":[\"rm\"]},\"application/vnd.rn-realmedia-vbr\":{\"source\":\"apache\",\"extensions\":[\"rmvb\"]},\"application/vnd.route66.link66+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"link66\"]},\"application/vnd.rs-274x\":{\"source\":\"iana\"},\"application/vnd.ruckus.download\":{\"source\":\"iana\"},\"application/vnd.s3sms\":{\"source\":\"iana\"},\"application/vnd.sailingtracker.track\":{\"source\":\"iana\",\"extensions\":[\"st\"]},\"application/vnd.sar\":{\"source\":\"iana\"},\"application/vnd.sbm.cid\":{\"source\":\"iana\"},\"application/vnd.sbm.mid2\":{\"source\":\"iana\"},\"application/vnd.scribus\":{\"source\":\"iana\"},\"application/vnd.sealed.3df\":{\"source\":\"iana\"},\"application/vnd.sealed.csf\":{\"source\":\"iana\"},\"application/vnd.sealed.doc\":{\"source\":\"iana\"},\"application/vnd.sealed.eml\":{\"source\":\"iana\"},\"application/vnd.sealed.mht\":{\"source\":\"iana\"},\"application/vnd.sealed.net\":{\"source\":\"iana\"},\"application/vnd.sealed.ppt\":{\"source\":\"iana\"},\"application/vnd.sealed.tiff\":{\"source\":\"iana\"},\"application/vnd.sealed.xls\":{\"source\":\"iana\"},\"application/vnd.sealedmedia.softseal.html\":{\"source\":\"iana\"},\"application/vnd.sealedmedia.softseal.pdf\":{\"source\":\"iana\"},\"application/vnd.seemail\":{\"source\":\"iana\",\"extensions\":[\"see\"]},\"application/vnd.sema\":{\"source\":\"iana\",\"extensions\":[\"sema\"]},\"application/vnd.semd\":{\"source\":\"iana\",\"extensions\":[\"semd\"]},\"application/vnd.semf\":{\"source\":\"iana\",\"extensions\":[\"semf\"]},\"application/vnd.shade-save-file\":{\"source\":\"iana\"},\"application/vnd.shana.informed.formdata\":{\"source\":\"iana\",\"extensions\":[\"ifm\"]},\"application/vnd.shana.informed.formtemplate\":{\"source\":\"iana\",\"extensions\":[\"itp\"]},\"application/vnd.shana.informed.interchange\":{\"source\":\"iana\",\"extensions\":[\"iif\"]},\"application/vnd.shana.informed.package\":{\"source\":\"iana\",\"extensions\":[\"ipk\"]},\"application/vnd.shootproof+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.shopkick+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.shp\":{\"source\":\"iana\"},\"application/vnd.shx\":{\"source\":\"iana\"},\"application/vnd.sigrok.session\":{\"source\":\"iana\"},\"application/vnd.simtech-mindmapper\":{\"source\":\"iana\",\"extensions\":[\"twd\",\"twds\"]},\"application/vnd.siren+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.smaf\":{\"source\":\"iana\",\"extensions\":[\"mmf\"]},\"application/vnd.smart.notebook\":{\"source\":\"iana\"},\"application/vnd.smart.teacher\":{\"source\":\"iana\",\"extensions\":[\"teacher\"]},\"application/vnd.snesdev-page-table\":{\"source\":\"iana\"},\"application/vnd.software602.filler.form+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"fo\"]},\"application/vnd.software602.filler.form-xml-zip\":{\"source\":\"iana\"},\"application/vnd.solent.sdkm+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"sdkm\",\"sdkd\"]},\"application/vnd.spotfire.dxp\":{\"source\":\"iana\",\"extensions\":[\"dxp\"]},\"application/vnd.spotfire.sfs\":{\"source\":\"iana\",\"extensions\":[\"sfs\"]},\"application/vnd.sqlite3\":{\"source\":\"iana\"},\"application/vnd.sss-cod\":{\"source\":\"iana\"},\"application/vnd.sss-dtf\":{\"source\":\"iana\"},\"application/vnd.sss-ntf\":{\"source\":\"iana\"},\"application/vnd.stardivision.calc\":{\"source\":\"apache\",\"extensions\":[\"sdc\"]},\"application/vnd.stardivision.draw\":{\"source\":\"apache\",\"extensions\":[\"sda\"]},\"application/vnd.stardivision.impress\":{\"source\":\"apache\",\"extensions\":[\"sdd\"]},\"application/vnd.stardivision.math\":{\"source\":\"apache\",\"extensions\":[\"smf\"]},\"application/vnd.stardivision.writer\":{\"source\":\"apache\",\"extensions\":[\"sdw\",\"vor\"]},\"application/vnd.stardivision.writer-global\":{\"source\":\"apache\",\"extensions\":[\"sgl\"]},\"application/vnd.stepmania.package\":{\"source\":\"iana\",\"extensions\":[\"smzip\"]},\"application/vnd.stepmania.stepchart\":{\"source\":\"iana\",\"extensions\":[\"sm\"]},\"application/vnd.street-stream\":{\"source\":\"iana\"},\"application/vnd.sun.wadl+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"wadl\"]},\"application/vnd.sun.xml.calc\":{\"source\":\"apache\",\"extensions\":[\"sxc\"]},\"application/vnd.sun.xml.calc.template\":{\"source\":\"apache\",\"extensions\":[\"stc\"]},\"application/vnd.sun.xml.draw\":{\"source\":\"apache\",\"extensions\":[\"sxd\"]},\"application/vnd.sun.xml.draw.template\":{\"source\":\"apache\",\"extensions\":[\"std\"]},\"application/vnd.sun.xml.impress\":{\"source\":\"apache\",\"extensions\":[\"sxi\"]},\"application/vnd.sun.xml.impress.template\":{\"source\":\"apache\",\"extensions\":[\"sti\"]},\"application/vnd.sun.xml.math\":{\"source\":\"apache\",\"extensions\":[\"sxm\"]},\"application/vnd.sun.xml.writer\":{\"source\":\"apache\",\"extensions\":[\"sxw\"]},\"application/vnd.sun.xml.writer.global\":{\"source\":\"apache\",\"extensions\":[\"sxg\"]},\"application/vnd.sun.xml.writer.template\":{\"source\":\"apache\",\"extensions\":[\"stw\"]},\"application/vnd.sus-calendar\":{\"source\":\"iana\",\"extensions\":[\"sus\",\"susp\"]},\"application/vnd.svd\":{\"source\":\"iana\",\"extensions\":[\"svd\"]},\"application/vnd.swiftview-ics\":{\"source\":\"iana\"},\"application/vnd.symbian.install\":{\"source\":\"apache\",\"extensions\":[\"sis\",\"sisx\"]},\"application/vnd.syncml+xml\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true,\"extensions\":[\"xsm\"]},\"application/vnd.syncml.dm+wbxml\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"extensions\":[\"bdm\"]},\"application/vnd.syncml.dm+xml\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true,\"extensions\":[\"xdm\"]},\"application/vnd.syncml.dm.notification\":{\"source\":\"iana\"},\"application/vnd.syncml.dmddf+wbxml\":{\"source\":\"iana\"},\"application/vnd.syncml.dmddf+xml\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true,\"extensions\":[\"ddf\"]},\"application/vnd.syncml.dmtnds+wbxml\":{\"source\":\"iana\"},\"application/vnd.syncml.dmtnds+xml\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true},\"application/vnd.syncml.ds.notification\":{\"source\":\"iana\"},\"application/vnd.tableschema+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.tao.intent-module-archive\":{\"source\":\"iana\",\"extensions\":[\"tao\"]},\"application/vnd.tcpdump.pcap\":{\"source\":\"iana\",\"extensions\":[\"pcap\",\"cap\",\"dmp\"]},\"application/vnd.think-cell.ppttc+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.tmd.mediaflex.api+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.tml\":{\"source\":\"iana\"},\"application/vnd.tmobile-livetv\":{\"source\":\"iana\",\"extensions\":[\"tmo\"]},\"application/vnd.tri.onesource\":{\"source\":\"iana\"},\"application/vnd.trid.tpt\":{\"source\":\"iana\",\"extensions\":[\"tpt\"]},\"application/vnd.triscape.mxs\":{\"source\":\"iana\",\"extensions\":[\"mxs\"]},\"application/vnd.trueapp\":{\"source\":\"iana\",\"extensions\":[\"tra\"]},\"application/vnd.truedoc\":{\"source\":\"iana\"},\"application/vnd.ubisoft.webplayer\":{\"source\":\"iana\"},\"application/vnd.ufdl\":{\"source\":\"iana\",\"extensions\":[\"ufd\",\"ufdl\"]},\"application/vnd.uiq.theme\":{\"source\":\"iana\",\"extensions\":[\"utz\"]},\"application/vnd.umajin\":{\"source\":\"iana\",\"extensions\":[\"umj\"]},\"application/vnd.unity\":{\"source\":\"iana\",\"extensions\":[\"unityweb\"]},\"application/vnd.uoml+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"uoml\"]},\"application/vnd.uplanet.alert\":{\"source\":\"iana\"},\"application/vnd.uplanet.alert-wbxml\":{\"source\":\"iana\"},\"application/vnd.uplanet.bearer-choice\":{\"source\":\"iana\"},\"application/vnd.uplanet.bearer-choice-wbxml\":{\"source\":\"iana\"},\"application/vnd.uplanet.cacheop\":{\"source\":\"iana\"},\"application/vnd.uplanet.cacheop-wbxml\":{\"source\":\"iana\"},\"application/vnd.uplanet.channel\":{\"source\":\"iana\"},\"application/vnd.uplanet.channel-wbxml\":{\"source\":\"iana\"},\"application/vnd.uplanet.list\":{\"source\":\"iana\"},\"application/vnd.uplanet.list-wbxml\":{\"source\":\"iana\"},\"application/vnd.uplanet.listcmd\":{\"source\":\"iana\"},\"application/vnd.uplanet.listcmd-wbxml\":{\"source\":\"iana\"},\"application/vnd.uplanet.signal\":{\"source\":\"iana\"},\"application/vnd.uri-map\":{\"source\":\"iana\"},\"application/vnd.valve.source.material\":{\"source\":\"iana\"},\"application/vnd.vcx\":{\"source\":\"iana\",\"extensions\":[\"vcx\"]},\"application/vnd.vd-study\":{\"source\":\"iana\"},\"application/vnd.vectorworks\":{\"source\":\"iana\"},\"application/vnd.vel+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.verimatrix.vcas\":{\"source\":\"iana\"},\"application/vnd.veryant.thin\":{\"source\":\"iana\"},\"application/vnd.ves.encrypted\":{\"source\":\"iana\"},\"application/vnd.vidsoft.vidconference\":{\"source\":\"iana\"},\"application/vnd.visio\":{\"source\":\"iana\",\"extensions\":[\"vsd\",\"vst\",\"vss\",\"vsw\"]},\"application/vnd.visionary\":{\"source\":\"iana\",\"extensions\":[\"vis\"]},\"application/vnd.vividence.scriptfile\":{\"source\":\"iana\"},\"application/vnd.vsf\":{\"source\":\"iana\",\"extensions\":[\"vsf\"]},\"application/vnd.wap.sic\":{\"source\":\"iana\"},\"application/vnd.wap.slc\":{\"source\":\"iana\"},\"application/vnd.wap.wbxml\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"extensions\":[\"wbxml\"]},\"application/vnd.wap.wmlc\":{\"source\":\"iana\",\"extensions\":[\"wmlc\"]},\"application/vnd.wap.wmlscriptc\":{\"source\":\"iana\",\"extensions\":[\"wmlsc\"]},\"application/vnd.webturbo\":{\"source\":\"iana\",\"extensions\":[\"wtb\"]},\"application/vnd.wfa.p2p\":{\"source\":\"iana\"},\"application/vnd.wfa.wsc\":{\"source\":\"iana\"},\"application/vnd.windows.devicepairing\":{\"source\":\"iana\"},\"application/vnd.wmc\":{\"source\":\"iana\"},\"application/vnd.wmf.bootstrap\":{\"source\":\"iana\"},\"application/vnd.wolfram.mathematica\":{\"source\":\"iana\"},\"application/vnd.wolfram.mathematica.package\":{\"source\":\"iana\"},\"application/vnd.wolfram.player\":{\"source\":\"iana\",\"extensions\":[\"nbp\"]},\"application/vnd.wordperfect\":{\"source\":\"iana\",\"extensions\":[\"wpd\"]},\"application/vnd.wqd\":{\"source\":\"iana\",\"extensions\":[\"wqd\"]},\"application/vnd.wrq-hp3000-labelled\":{\"source\":\"iana\"},\"application/vnd.wt.stf\":{\"source\":\"iana\",\"extensions\":[\"stf\"]},\"application/vnd.wv.csp+wbxml\":{\"source\":\"iana\"},\"application/vnd.wv.csp+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.wv.ssp+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.xacml+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.xara\":{\"source\":\"iana\",\"extensions\":[\"xar\"]},\"application/vnd.xfdl\":{\"source\":\"iana\",\"extensions\":[\"xfdl\"]},\"application/vnd.xfdl.webform\":{\"source\":\"iana\"},\"application/vnd.xmi+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/vnd.xmpie.cpkg\":{\"source\":\"iana\"},\"application/vnd.xmpie.dpkg\":{\"source\":\"iana\"},\"application/vnd.xmpie.plan\":{\"source\":\"iana\"},\"application/vnd.xmpie.ppkg\":{\"source\":\"iana\"},\"application/vnd.xmpie.xlim\":{\"source\":\"iana\"},\"application/vnd.yamaha.hv-dic\":{\"source\":\"iana\",\"extensions\":[\"hvd\"]},\"application/vnd.yamaha.hv-script\":{\"source\":\"iana\",\"extensions\":[\"hvs\"]},\"application/vnd.yamaha.hv-voice\":{\"source\":\"iana\",\"extensions\":[\"hvp\"]},\"application/vnd.yamaha.openscoreformat\":{\"source\":\"iana\",\"extensions\":[\"osf\"]},\"application/vnd.yamaha.openscoreformat.osfpvg+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"osfpvg\"]},\"application/vnd.yamaha.remote-setup\":{\"source\":\"iana\"},\"application/vnd.yamaha.smaf-audio\":{\"source\":\"iana\",\"extensions\":[\"saf\"]},\"application/vnd.yamaha.smaf-phrase\":{\"source\":\"iana\",\"extensions\":[\"spf\"]},\"application/vnd.yamaha.through-ngn\":{\"source\":\"iana\"},\"application/vnd.yamaha.tunnel-udpencap\":{\"source\":\"iana\"},\"application/vnd.yaoweme\":{\"source\":\"iana\"},\"application/vnd.yellowriver-custom-menu\":{\"source\":\"iana\",\"extensions\":[\"cmp\"]},\"application/vnd.youtube.yt\":{\"source\":\"iana\"},\"application/vnd.zul\":{\"source\":\"iana\",\"extensions\":[\"zir\",\"zirz\"]},\"application/vnd.zzazz.deck+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"zaz\"]},\"application/voicexml+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"vxml\"]},\"application/voucher-cms+json\":{\"source\":\"iana\",\"compressible\":true},\"application/vq-rtcpxr\":{\"source\":\"iana\"},\"application/wasm\":{\"compressible\":true,\"extensions\":[\"wasm\"]},\"application/watcherinfo+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/webpush-options+json\":{\"source\":\"iana\",\"compressible\":true},\"application/whoispp-query\":{\"source\":\"iana\"},\"application/whoispp-response\":{\"source\":\"iana\"},\"application/widget\":{\"source\":\"iana\",\"extensions\":[\"wgt\"]},\"application/winhlp\":{\"source\":\"apache\",\"extensions\":[\"hlp\"]},\"application/wita\":{\"source\":\"iana\"},\"application/wordperfect5.1\":{\"source\":\"iana\"},\"application/wsdl+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"wsdl\"]},\"application/wspolicy+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"wspolicy\"]},\"application/x-7z-compressed\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"7z\"]},\"application/x-abiword\":{\"source\":\"apache\",\"extensions\":[\"abw\"]},\"application/x-ace-compressed\":{\"source\":\"apache\",\"extensions\":[\"ace\"]},\"application/x-amf\":{\"source\":\"apache\"},\"application/x-apple-diskimage\":{\"source\":\"apache\",\"extensions\":[\"dmg\"]},\"application/x-arj\":{\"compressible\":false,\"extensions\":[\"arj\"]},\"application/x-authorware-bin\":{\"source\":\"apache\",\"extensions\":[\"aab\",\"x32\",\"u32\",\"vox\"]},\"application/x-authorware-map\":{\"source\":\"apache\",\"extensions\":[\"aam\"]},\"application/x-authorware-seg\":{\"source\":\"apache\",\"extensions\":[\"aas\"]},\"application/x-bcpio\":{\"source\":\"apache\",\"extensions\":[\"bcpio\"]},\"application/x-bdoc\":{\"compressible\":false,\"extensions\":[\"bdoc\"]},\"application/x-bittorrent\":{\"source\":\"apache\",\"extensions\":[\"torrent\"]},\"application/x-blorb\":{\"source\":\"apache\",\"extensions\":[\"blb\",\"blorb\"]},\"application/x-bzip\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"bz\"]},\"application/x-bzip2\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"bz2\",\"boz\"]},\"application/x-cbr\":{\"source\":\"apache\",\"extensions\":[\"cbr\",\"cba\",\"cbt\",\"cbz\",\"cb7\"]},\"application/x-cdlink\":{\"source\":\"apache\",\"extensions\":[\"vcd\"]},\"application/x-cfs-compressed\":{\"source\":\"apache\",\"extensions\":[\"cfs\"]},\"application/x-chat\":{\"source\":\"apache\",\"extensions\":[\"chat\"]},\"application/x-chess-pgn\":{\"source\":\"apache\",\"extensions\":[\"pgn\"]},\"application/x-chrome-extension\":{\"extensions\":[\"crx\"]},\"application/x-cocoa\":{\"source\":\"nginx\",\"extensions\":[\"cco\"]},\"application/x-compress\":{\"source\":\"apache\"},\"application/x-conference\":{\"source\":\"apache\",\"extensions\":[\"nsc\"]},\"application/x-cpio\":{\"source\":\"apache\",\"extensions\":[\"cpio\"]},\"application/x-csh\":{\"source\":\"apache\",\"extensions\":[\"csh\"]},\"application/x-deb\":{\"compressible\":false},\"application/x-debian-package\":{\"source\":\"apache\",\"extensions\":[\"deb\",\"udeb\"]},\"application/x-dgc-compressed\":{\"source\":\"apache\",\"extensions\":[\"dgc\"]},\"application/x-director\":{\"source\":\"apache\",\"extensions\":[\"dir\",\"dcr\",\"dxr\",\"cst\",\"cct\",\"cxt\",\"w3d\",\"fgd\",\"swa\"]},\"application/x-doom\":{\"source\":\"apache\",\"extensions\":[\"wad\"]},\"application/x-dtbncx+xml\":{\"source\":\"apache\",\"compressible\":true,\"extensions\":[\"ncx\"]},\"application/x-dtbook+xml\":{\"source\":\"apache\",\"compressible\":true,\"extensions\":[\"dtb\"]},\"application/x-dtbresource+xml\":{\"source\":\"apache\",\"compressible\":true,\"extensions\":[\"res\"]},\"application/x-dvi\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"dvi\"]},\"application/x-envoy\":{\"source\":\"apache\",\"extensions\":[\"evy\"]},\"application/x-eva\":{\"source\":\"apache\",\"extensions\":[\"eva\"]},\"application/x-font-bdf\":{\"source\":\"apache\",\"extensions\":[\"bdf\"]},\"application/x-font-dos\":{\"source\":\"apache\"},\"application/x-font-framemaker\":{\"source\":\"apache\"},\"application/x-font-ghostscript\":{\"source\":\"apache\",\"extensions\":[\"gsf\"]},\"application/x-font-libgrx\":{\"source\":\"apache\"},\"application/x-font-linux-psf\":{\"source\":\"apache\",\"extensions\":[\"psf\"]},\"application/x-font-pcf\":{\"source\":\"apache\",\"extensions\":[\"pcf\"]},\"application/x-font-snf\":{\"source\":\"apache\",\"extensions\":[\"snf\"]},\"application/x-font-speedo\":{\"source\":\"apache\"},\"application/x-font-sunos-news\":{\"source\":\"apache\"},\"application/x-font-type1\":{\"source\":\"apache\",\"extensions\":[\"pfa\",\"pfb\",\"pfm\",\"afm\"]},\"application/x-font-vfont\":{\"source\":\"apache\"},\"application/x-freearc\":{\"source\":\"apache\",\"extensions\":[\"arc\"]},\"application/x-futuresplash\":{\"source\":\"apache\",\"extensions\":[\"spl\"]},\"application/x-gca-compressed\":{\"source\":\"apache\",\"extensions\":[\"gca\"]},\"application/x-glulx\":{\"source\":\"apache\",\"extensions\":[\"ulx\"]},\"application/x-gnumeric\":{\"source\":\"apache\",\"extensions\":[\"gnumeric\"]},\"application/x-gramps-xml\":{\"source\":\"apache\",\"extensions\":[\"gramps\"]},\"application/x-gtar\":{\"source\":\"apache\",\"extensions\":[\"gtar\"]},\"application/x-gzip\":{\"source\":\"apache\"},\"application/x-hdf\":{\"source\":\"apache\",\"extensions\":[\"hdf\"]},\"application/x-httpd-php\":{\"compressible\":true,\"extensions\":[\"php\"]},\"application/x-install-instructions\":{\"source\":\"apache\",\"extensions\":[\"install\"]},\"application/x-iso9660-image\":{\"source\":\"apache\",\"extensions\":[\"iso\"]},\"application/x-java-archive-diff\":{\"source\":\"nginx\",\"extensions\":[\"jardiff\"]},\"application/x-java-jnlp-file\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"jnlp\"]},\"application/x-javascript\":{\"compressible\":true},\"application/x-keepass2\":{\"extensions\":[\"kdbx\"]},\"application/x-latex\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"latex\"]},\"application/x-lua-bytecode\":{\"extensions\":[\"luac\"]},\"application/x-lzh-compressed\":{\"source\":\"apache\",\"extensions\":[\"lzh\",\"lha\"]},\"application/x-makeself\":{\"source\":\"nginx\",\"extensions\":[\"run\"]},\"application/x-mie\":{\"source\":\"apache\",\"extensions\":[\"mie\"]},\"application/x-mobipocket-ebook\":{\"source\":\"apache\",\"extensions\":[\"prc\",\"mobi\"]},\"application/x-mpegurl\":{\"compressible\":false},\"application/x-ms-application\":{\"source\":\"apache\",\"extensions\":[\"application\"]},\"application/x-ms-shortcut\":{\"source\":\"apache\",\"extensions\":[\"lnk\"]},\"application/x-ms-wmd\":{\"source\":\"apache\",\"extensions\":[\"wmd\"]},\"application/x-ms-wmz\":{\"source\":\"apache\",\"extensions\":[\"wmz\"]},\"application/x-ms-xbap\":{\"source\":\"apache\",\"extensions\":[\"xbap\"]},\"application/x-msaccess\":{\"source\":\"apache\",\"extensions\":[\"mdb\"]},\"application/x-msbinder\":{\"source\":\"apache\",\"extensions\":[\"obd\"]},\"application/x-mscardfile\":{\"source\":\"apache\",\"extensions\":[\"crd\"]},\"application/x-msclip\":{\"source\":\"apache\",\"extensions\":[\"clp\"]},\"application/x-msdos-program\":{\"extensions\":[\"exe\"]},\"application/x-msdownload\":{\"source\":\"apache\",\"extensions\":[\"exe\",\"dll\",\"com\",\"bat\",\"msi\"]},\"application/x-msmediaview\":{\"source\":\"apache\",\"extensions\":[\"mvb\",\"m13\",\"m14\"]},\"application/x-msmetafile\":{\"source\":\"apache\",\"extensions\":[\"wmf\",\"wmz\",\"emf\",\"emz\"]},\"application/x-msmoney\":{\"source\":\"apache\",\"extensions\":[\"mny\"]},\"application/x-mspublisher\":{\"source\":\"apache\",\"extensions\":[\"pub\"]},\"application/x-msschedule\":{\"source\":\"apache\",\"extensions\":[\"scd\"]},\"application/x-msterminal\":{\"source\":\"apache\",\"extensions\":[\"trm\"]},\"application/x-mswrite\":{\"source\":\"apache\",\"extensions\":[\"wri\"]},\"application/x-netcdf\":{\"source\":\"apache\",\"extensions\":[\"nc\",\"cdf\"]},\"application/x-ns-proxy-autoconfig\":{\"compressible\":true,\"extensions\":[\"pac\"]},\"application/x-nzb\":{\"source\":\"apache\",\"extensions\":[\"nzb\"]},\"application/x-perl\":{\"source\":\"nginx\",\"extensions\":[\"pl\",\"pm\"]},\"application/x-pilot\":{\"source\":\"nginx\",\"extensions\":[\"prc\",\"pdb\"]},\"application/x-pkcs12\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"p12\",\"pfx\"]},\"application/x-pkcs7-certificates\":{\"source\":\"apache\",\"extensions\":[\"p7b\",\"spc\"]},\"application/x-pkcs7-certreqresp\":{\"source\":\"apache\",\"extensions\":[\"p7r\"]},\"application/x-pki-message\":{\"source\":\"iana\"},\"application/x-rar-compressed\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"rar\"]},\"application/x-redhat-package-manager\":{\"source\":\"nginx\",\"extensions\":[\"rpm\"]},\"application/x-research-info-systems\":{\"source\":\"apache\",\"extensions\":[\"ris\"]},\"application/x-sea\":{\"source\":\"nginx\",\"extensions\":[\"sea\"]},\"application/x-sh\":{\"source\":\"apache\",\"compressible\":true,\"extensions\":[\"sh\"]},\"application/x-shar\":{\"source\":\"apache\",\"extensions\":[\"shar\"]},\"application/x-shockwave-flash\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"swf\"]},\"application/x-silverlight-app\":{\"source\":\"apache\",\"extensions\":[\"xap\"]},\"application/x-sql\":{\"source\":\"apache\",\"extensions\":[\"sql\"]},\"application/x-stuffit\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"sit\"]},\"application/x-stuffitx\":{\"source\":\"apache\",\"extensions\":[\"sitx\"]},\"application/x-subrip\":{\"source\":\"apache\",\"extensions\":[\"srt\"]},\"application/x-sv4cpio\":{\"source\":\"apache\",\"extensions\":[\"sv4cpio\"]},\"application/x-sv4crc\":{\"source\":\"apache\",\"extensions\":[\"sv4crc\"]},\"application/x-t3vm-image\":{\"source\":\"apache\",\"extensions\":[\"t3\"]},\"application/x-tads\":{\"source\":\"apache\",\"extensions\":[\"gam\"]},\"application/x-tar\":{\"source\":\"apache\",\"compressible\":true,\"extensions\":[\"tar\"]},\"application/x-tcl\":{\"source\":\"apache\",\"extensions\":[\"tcl\",\"tk\"]},\"application/x-tex\":{\"source\":\"apache\",\"extensions\":[\"tex\"]},\"application/x-tex-tfm\":{\"source\":\"apache\",\"extensions\":[\"tfm\"]},\"application/x-texinfo\":{\"source\":\"apache\",\"extensions\":[\"texinfo\",\"texi\"]},\"application/x-tgif\":{\"source\":\"apache\",\"extensions\":[\"obj\"]},\"application/x-ustar\":{\"source\":\"apache\",\"extensions\":[\"ustar\"]},\"application/x-virtualbox-hdd\":{\"compressible\":true,\"extensions\":[\"hdd\"]},\"application/x-virtualbox-ova\":{\"compressible\":true,\"extensions\":[\"ova\"]},\"application/x-virtualbox-ovf\":{\"compressible\":true,\"extensions\":[\"ovf\"]},\"application/x-virtualbox-vbox\":{\"compressible\":true,\"extensions\":[\"vbox\"]},\"application/x-virtualbox-vbox-extpack\":{\"compressible\":false,\"extensions\":[\"vbox-extpack\"]},\"application/x-virtualbox-vdi\":{\"compressible\":true,\"extensions\":[\"vdi\"]},\"application/x-virtualbox-vhd\":{\"compressible\":true,\"extensions\":[\"vhd\"]},\"application/x-virtualbox-vmdk\":{\"compressible\":true,\"extensions\":[\"vmdk\"]},\"application/x-wais-source\":{\"source\":\"apache\",\"extensions\":[\"src\"]},\"application/x-web-app-manifest+json\":{\"compressible\":true,\"extensions\":[\"webapp\"]},\"application/x-www-form-urlencoded\":{\"source\":\"iana\",\"compressible\":true},\"application/x-x509-ca-cert\":{\"source\":\"iana\",\"extensions\":[\"der\",\"crt\",\"pem\"]},\"application/x-x509-ca-ra-cert\":{\"source\":\"iana\"},\"application/x-x509-next-ca-cert\":{\"source\":\"iana\"},\"application/x-xfig\":{\"source\":\"apache\",\"extensions\":[\"fig\"]},\"application/x-xliff+xml\":{\"source\":\"apache\",\"compressible\":true,\"extensions\":[\"xlf\"]},\"application/x-xpinstall\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"xpi\"]},\"application/x-xz\":{\"source\":\"apache\",\"extensions\":[\"xz\"]},\"application/x-zmachine\":{\"source\":\"apache\",\"extensions\":[\"z1\",\"z2\",\"z3\",\"z4\",\"z5\",\"z6\",\"z7\",\"z8\"]},\"application/x400-bp\":{\"source\":\"iana\"},\"application/xacml+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/xaml+xml\":{\"source\":\"apache\",\"compressible\":true,\"extensions\":[\"xaml\"]},\"application/xcap-att+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"xav\"]},\"application/xcap-caps+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"xca\"]},\"application/xcap-diff+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"xdf\"]},\"application/xcap-el+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"xel\"]},\"application/xcap-error+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"xer\"]},\"application/xcap-ns+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"xns\"]},\"application/xcon-conference-info+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/xcon-conference-info-diff+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/xenc+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"xenc\"]},\"application/xhtml+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"xhtml\",\"xht\"]},\"application/xhtml-voice+xml\":{\"source\":\"apache\",\"compressible\":true},\"application/xliff+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"xlf\"]},\"application/xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"xml\",\"xsl\",\"xsd\",\"rng\"]},\"application/xml-dtd\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"dtd\"]},\"application/xml-external-parsed-entity\":{\"source\":\"iana\"},\"application/xml-patch+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/xmpp+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/xop+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"xop\"]},\"application/xproc+xml\":{\"source\":\"apache\",\"compressible\":true,\"extensions\":[\"xpl\"]},\"application/xslt+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"xslt\"]},\"application/xspf+xml\":{\"source\":\"apache\",\"compressible\":true,\"extensions\":[\"xspf\"]},\"application/xv+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"mxml\",\"xhvml\",\"xvml\",\"xvm\"]},\"application/yang\":{\"source\":\"iana\",\"extensions\":[\"yang\"]},\"application/yang-data+json\":{\"source\":\"iana\",\"compressible\":true},\"application/yang-data+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/yang-patch+json\":{\"source\":\"iana\",\"compressible\":true},\"application/yang-patch+xml\":{\"source\":\"iana\",\"compressible\":true},\"application/yin+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"yin\"]},\"application/zip\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"zip\"]},\"application/zlib\":{\"source\":\"iana\"},\"application/zstd\":{\"source\":\"iana\"},\"audio/1d-interleaved-parityfec\":{\"source\":\"iana\"},\"audio/32kadpcm\":{\"source\":\"iana\"},\"audio/3gpp\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"3gpp\"]},\"audio/3gpp2\":{\"source\":\"iana\"},\"audio/aac\":{\"source\":\"iana\"},\"audio/ac3\":{\"source\":\"iana\"},\"audio/adpcm\":{\"source\":\"apache\",\"extensions\":[\"adp\"]},\"audio/amr\":{\"source\":\"iana\"},\"audio/amr-wb\":{\"source\":\"iana\"},\"audio/amr-wb+\":{\"source\":\"iana\"},\"audio/aptx\":{\"source\":\"iana\"},\"audio/asc\":{\"source\":\"iana\"},\"audio/atrac-advanced-lossless\":{\"source\":\"iana\"},\"audio/atrac-x\":{\"source\":\"iana\"},\"audio/atrac3\":{\"source\":\"iana\"},\"audio/basic\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"au\",\"snd\"]},\"audio/bv16\":{\"source\":\"iana\"},\"audio/bv32\":{\"source\":\"iana\"},\"audio/clearmode\":{\"source\":\"iana\"},\"audio/cn\":{\"source\":\"iana\"},\"audio/dat12\":{\"source\":\"iana\"},\"audio/dls\":{\"source\":\"iana\"},\"audio/dsr-es201108\":{\"source\":\"iana\"},\"audio/dsr-es202050\":{\"source\":\"iana\"},\"audio/dsr-es202211\":{\"source\":\"iana\"},\"audio/dsr-es202212\":{\"source\":\"iana\"},\"audio/dv\":{\"source\":\"iana\"},\"audio/dvi4\":{\"source\":\"iana\"},\"audio/eac3\":{\"source\":\"iana\"},\"audio/encaprtp\":{\"source\":\"iana\"},\"audio/evrc\":{\"source\":\"iana\"},\"audio/evrc-qcp\":{\"source\":\"iana\"},\"audio/evrc0\":{\"source\":\"iana\"},\"audio/evrc1\":{\"source\":\"iana\"},\"audio/evrcb\":{\"source\":\"iana\"},\"audio/evrcb0\":{\"source\":\"iana\"},\"audio/evrcb1\":{\"source\":\"iana\"},\"audio/evrcnw\":{\"source\":\"iana\"},\"audio/evrcnw0\":{\"source\":\"iana\"},\"audio/evrcnw1\":{\"source\":\"iana\"},\"audio/evrcwb\":{\"source\":\"iana\"},\"audio/evrcwb0\":{\"source\":\"iana\"},\"audio/evrcwb1\":{\"source\":\"iana\"},\"audio/evs\":{\"source\":\"iana\"},\"audio/flexfec\":{\"source\":\"iana\"},\"audio/fwdred\":{\"source\":\"iana\"},\"audio/g711-0\":{\"source\":\"iana\"},\"audio/g719\":{\"source\":\"iana\"},\"audio/g722\":{\"source\":\"iana\"},\"audio/g7221\":{\"source\":\"iana\"},\"audio/g723\":{\"source\":\"iana\"},\"audio/g726-16\":{\"source\":\"iana\"},\"audio/g726-24\":{\"source\":\"iana\"},\"audio/g726-32\":{\"source\":\"iana\"},\"audio/g726-40\":{\"source\":\"iana\"},\"audio/g728\":{\"source\":\"iana\"},\"audio/g729\":{\"source\":\"iana\"},\"audio/g7291\":{\"source\":\"iana\"},\"audio/g729d\":{\"source\":\"iana\"},\"audio/g729e\":{\"source\":\"iana\"},\"audio/gsm\":{\"source\":\"iana\"},\"audio/gsm-efr\":{\"source\":\"iana\"},\"audio/gsm-hr-08\":{\"source\":\"iana\"},\"audio/ilbc\":{\"source\":\"iana\"},\"audio/ip-mr_v2.5\":{\"source\":\"iana\"},\"audio/isac\":{\"source\":\"apache\"},\"audio/l16\":{\"source\":\"iana\"},\"audio/l20\":{\"source\":\"iana\"},\"audio/l24\":{\"source\":\"iana\",\"compressible\":false},\"audio/l8\":{\"source\":\"iana\"},\"audio/lpc\":{\"source\":\"iana\"},\"audio/melp\":{\"source\":\"iana\"},\"audio/melp1200\":{\"source\":\"iana\"},\"audio/melp2400\":{\"source\":\"iana\"},\"audio/melp600\":{\"source\":\"iana\"},\"audio/mhas\":{\"source\":\"iana\"},\"audio/midi\":{\"source\":\"apache\",\"extensions\":[\"mid\",\"midi\",\"kar\",\"rmi\"]},\"audio/mobile-xmf\":{\"source\":\"iana\",\"extensions\":[\"mxmf\"]},\"audio/mp3\":{\"compressible\":false,\"extensions\":[\"mp3\"]},\"audio/mp4\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"m4a\",\"mp4a\"]},\"audio/mp4a-latm\":{\"source\":\"iana\"},\"audio/mpa\":{\"source\":\"iana\"},\"audio/mpa-robust\":{\"source\":\"iana\"},\"audio/mpeg\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"mpga\",\"mp2\",\"mp2a\",\"mp3\",\"m2a\",\"m3a\"]},\"audio/mpeg4-generic\":{\"source\":\"iana\"},\"audio/musepack\":{\"source\":\"apache\"},\"audio/ogg\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"oga\",\"ogg\",\"spx\"]},\"audio/opus\":{\"source\":\"iana\"},\"audio/parityfec\":{\"source\":\"iana\"},\"audio/pcma\":{\"source\":\"iana\"},\"audio/pcma-wb\":{\"source\":\"iana\"},\"audio/pcmu\":{\"source\":\"iana\"},\"audio/pcmu-wb\":{\"source\":\"iana\"},\"audio/prs.sid\":{\"source\":\"iana\"},\"audio/qcelp\":{\"source\":\"iana\"},\"audio/raptorfec\":{\"source\":\"iana\"},\"audio/red\":{\"source\":\"iana\"},\"audio/rtp-enc-aescm128\":{\"source\":\"iana\"},\"audio/rtp-midi\":{\"source\":\"iana\"},\"audio/rtploopback\":{\"source\":\"iana\"},\"audio/rtx\":{\"source\":\"iana\"},\"audio/s3m\":{\"source\":\"apache\",\"extensions\":[\"s3m\"]},\"audio/silk\":{\"source\":\"apache\",\"extensions\":[\"sil\"]},\"audio/smv\":{\"source\":\"iana\"},\"audio/smv-qcp\":{\"source\":\"iana\"},\"audio/smv0\":{\"source\":\"iana\"},\"audio/sp-midi\":{\"source\":\"iana\"},\"audio/speex\":{\"source\":\"iana\"},\"audio/t140c\":{\"source\":\"iana\"},\"audio/t38\":{\"source\":\"iana\"},\"audio/telephone-event\":{\"source\":\"iana\"},\"audio/tetra_acelp\":{\"source\":\"iana\"},\"audio/tetra_acelp_bb\":{\"source\":\"iana\"},\"audio/tone\":{\"source\":\"iana\"},\"audio/uemclip\":{\"source\":\"iana\"},\"audio/ulpfec\":{\"source\":\"iana\"},\"audio/usac\":{\"source\":\"iana\"},\"audio/vdvi\":{\"source\":\"iana\"},\"audio/vmr-wb\":{\"source\":\"iana\"},\"audio/vnd.3gpp.iufp\":{\"source\":\"iana\"},\"audio/vnd.4sb\":{\"source\":\"iana\"},\"audio/vnd.audiokoz\":{\"source\":\"iana\"},\"audio/vnd.celp\":{\"source\":\"iana\"},\"audio/vnd.cisco.nse\":{\"source\":\"iana\"},\"audio/vnd.cmles.radio-events\":{\"source\":\"iana\"},\"audio/vnd.cns.anp1\":{\"source\":\"iana\"},\"audio/vnd.cns.inf1\":{\"source\":\"iana\"},\"audio/vnd.dece.audio\":{\"source\":\"iana\",\"extensions\":[\"uva\",\"uvva\"]},\"audio/vnd.digital-winds\":{\"source\":\"iana\",\"extensions\":[\"eol\"]},\"audio/vnd.dlna.adts\":{\"source\":\"iana\"},\"audio/vnd.dolby.heaac.1\":{\"source\":\"iana\"},\"audio/vnd.dolby.heaac.2\":{\"source\":\"iana\"},\"audio/vnd.dolby.mlp\":{\"source\":\"iana\"},\"audio/vnd.dolby.mps\":{\"source\":\"iana\"},\"audio/vnd.dolby.pl2\":{\"source\":\"iana\"},\"audio/vnd.dolby.pl2x\":{\"source\":\"iana\"},\"audio/vnd.dolby.pl2z\":{\"source\":\"iana\"},\"audio/vnd.dolby.pulse.1\":{\"source\":\"iana\"},\"audio/vnd.dra\":{\"source\":\"iana\",\"extensions\":[\"dra\"]},\"audio/vnd.dts\":{\"source\":\"iana\",\"extensions\":[\"dts\"]},\"audio/vnd.dts.hd\":{\"source\":\"iana\",\"extensions\":[\"dtshd\"]},\"audio/vnd.dts.uhd\":{\"source\":\"iana\"},\"audio/vnd.dvb.file\":{\"source\":\"iana\"},\"audio/vnd.everad.plj\":{\"source\":\"iana\"},\"audio/vnd.hns.audio\":{\"source\":\"iana\"},\"audio/vnd.lucent.voice\":{\"source\":\"iana\",\"extensions\":[\"lvp\"]},\"audio/vnd.ms-playready.media.pya\":{\"source\":\"iana\",\"extensions\":[\"pya\"]},\"audio/vnd.nokia.mobile-xmf\":{\"source\":\"iana\"},\"audio/vnd.nortel.vbk\":{\"source\":\"iana\"},\"audio/vnd.nuera.ecelp4800\":{\"source\":\"iana\",\"extensions\":[\"ecelp4800\"]},\"audio/vnd.nuera.ecelp7470\":{\"source\":\"iana\",\"extensions\":[\"ecelp7470\"]},\"audio/vnd.nuera.ecelp9600\":{\"source\":\"iana\",\"extensions\":[\"ecelp9600\"]},\"audio/vnd.octel.sbc\":{\"source\":\"iana\"},\"audio/vnd.presonus.multitrack\":{\"source\":\"iana\"},\"audio/vnd.qcelp\":{\"source\":\"iana\"},\"audio/vnd.rhetorex.32kadpcm\":{\"source\":\"iana\"},\"audio/vnd.rip\":{\"source\":\"iana\",\"extensions\":[\"rip\"]},\"audio/vnd.rn-realaudio\":{\"compressible\":false},\"audio/vnd.sealedmedia.softseal.mpeg\":{\"source\":\"iana\"},\"audio/vnd.vmx.cvsd\":{\"source\":\"iana\"},\"audio/vnd.wave\":{\"compressible\":false},\"audio/vorbis\":{\"source\":\"iana\",\"compressible\":false},\"audio/vorbis-config\":{\"source\":\"iana\"},\"audio/wav\":{\"compressible\":false,\"extensions\":[\"wav\"]},\"audio/wave\":{\"compressible\":false,\"extensions\":[\"wav\"]},\"audio/webm\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"weba\"]},\"audio/x-aac\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"aac\"]},\"audio/x-aiff\":{\"source\":\"apache\",\"extensions\":[\"aif\",\"aiff\",\"aifc\"]},\"audio/x-caf\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"caf\"]},\"audio/x-flac\":{\"source\":\"apache\",\"extensions\":[\"flac\"]},\"audio/x-m4a\":{\"source\":\"nginx\",\"extensions\":[\"m4a\"]},\"audio/x-matroska\":{\"source\":\"apache\",\"extensions\":[\"mka\"]},\"audio/x-mpegurl\":{\"source\":\"apache\",\"extensions\":[\"m3u\"]},\"audio/x-ms-wax\":{\"source\":\"apache\",\"extensions\":[\"wax\"]},\"audio/x-ms-wma\":{\"source\":\"apache\",\"extensions\":[\"wma\"]},\"audio/x-pn-realaudio\":{\"source\":\"apache\",\"extensions\":[\"ram\",\"ra\"]},\"audio/x-pn-realaudio-plugin\":{\"source\":\"apache\",\"extensions\":[\"rmp\"]},\"audio/x-realaudio\":{\"source\":\"nginx\",\"extensions\":[\"ra\"]},\"audio/x-tta\":{\"source\":\"apache\"},\"audio/x-wav\":{\"source\":\"apache\",\"extensions\":[\"wav\"]},\"audio/xm\":{\"source\":\"apache\",\"extensions\":[\"xm\"]},\"chemical/x-cdx\":{\"source\":\"apache\",\"extensions\":[\"cdx\"]},\"chemical/x-cif\":{\"source\":\"apache\",\"extensions\":[\"cif\"]},\"chemical/x-cmdf\":{\"source\":\"apache\",\"extensions\":[\"cmdf\"]},\"chemical/x-cml\":{\"source\":\"apache\",\"extensions\":[\"cml\"]},\"chemical/x-csml\":{\"source\":\"apache\",\"extensions\":[\"csml\"]},\"chemical/x-pdb\":{\"source\":\"apache\"},\"chemical/x-xyz\":{\"source\":\"apache\",\"extensions\":[\"xyz\"]},\"font/collection\":{\"source\":\"iana\",\"extensions\":[\"ttc\"]},\"font/otf\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"otf\"]},\"font/sfnt\":{\"source\":\"iana\"},\"font/ttf\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"ttf\"]},\"font/woff\":{\"source\":\"iana\",\"extensions\":[\"woff\"]},\"font/woff2\":{\"source\":\"iana\",\"extensions\":[\"woff2\"]},\"image/aces\":{\"source\":\"iana\",\"extensions\":[\"exr\"]},\"image/apng\":{\"compressible\":false,\"extensions\":[\"apng\"]},\"image/avci\":{\"source\":\"iana\"},\"image/avcs\":{\"source\":\"iana\"},\"image/bmp\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"bmp\"]},\"image/cgm\":{\"source\":\"iana\",\"extensions\":[\"cgm\"]},\"image/dicom-rle\":{\"source\":\"iana\",\"extensions\":[\"drle\"]},\"image/emf\":{\"source\":\"iana\",\"extensions\":[\"emf\"]},\"image/fits\":{\"source\":\"iana\",\"extensions\":[\"fits\"]},\"image/g3fax\":{\"source\":\"iana\",\"extensions\":[\"g3\"]},\"image/gif\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"gif\"]},\"image/heic\":{\"source\":\"iana\",\"extensions\":[\"heic\"]},\"image/heic-sequence\":{\"source\":\"iana\",\"extensions\":[\"heics\"]},\"image/heif\":{\"source\":\"iana\",\"extensions\":[\"heif\"]},\"image/heif-sequence\":{\"source\":\"iana\",\"extensions\":[\"heifs\"]},\"image/hej2k\":{\"source\":\"iana\",\"extensions\":[\"hej2\"]},\"image/hsj2\":{\"source\":\"iana\",\"extensions\":[\"hsj2\"]},\"image/ief\":{\"source\":\"iana\",\"extensions\":[\"ief\"]},\"image/jls\":{\"source\":\"iana\",\"extensions\":[\"jls\"]},\"image/jp2\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"jp2\",\"jpg2\"]},\"image/jpeg\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"jpeg\",\"jpg\",\"jpe\"]},\"image/jph\":{\"source\":\"iana\",\"extensions\":[\"jph\"]},\"image/jphc\":{\"source\":\"iana\",\"extensions\":[\"jhc\"]},\"image/jpm\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"jpm\"]},\"image/jpx\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"jpx\",\"jpf\"]},\"image/jxr\":{\"source\":\"iana\",\"extensions\":[\"jxr\"]},\"image/jxra\":{\"source\":\"iana\",\"extensions\":[\"jxra\"]},\"image/jxrs\":{\"source\":\"iana\",\"extensions\":[\"jxrs\"]},\"image/jxs\":{\"source\":\"iana\",\"extensions\":[\"jxs\"]},\"image/jxsc\":{\"source\":\"iana\",\"extensions\":[\"jxsc\"]},\"image/jxsi\":{\"source\":\"iana\",\"extensions\":[\"jxsi\"]},\"image/jxss\":{\"source\":\"iana\",\"extensions\":[\"jxss\"]},\"image/ktx\":{\"source\":\"iana\",\"extensions\":[\"ktx\"]},\"image/naplps\":{\"source\":\"iana\"},\"image/pjpeg\":{\"compressible\":false},\"image/png\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"png\"]},\"image/prs.btif\":{\"source\":\"iana\",\"extensions\":[\"btif\"]},\"image/prs.pti\":{\"source\":\"iana\",\"extensions\":[\"pti\"]},\"image/pwg-raster\":{\"source\":\"iana\"},\"image/sgi\":{\"source\":\"apache\",\"extensions\":[\"sgi\"]},\"image/svg+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"svg\",\"svgz\"]},\"image/t38\":{\"source\":\"iana\",\"extensions\":[\"t38\"]},\"image/tiff\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"tif\",\"tiff\"]},\"image/tiff-fx\":{\"source\":\"iana\",\"extensions\":[\"tfx\"]},\"image/vnd.adobe.photoshop\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"psd\"]},\"image/vnd.airzip.accelerator.azv\":{\"source\":\"iana\",\"extensions\":[\"azv\"]},\"image/vnd.cns.inf2\":{\"source\":\"iana\"},\"image/vnd.dece.graphic\":{\"source\":\"iana\",\"extensions\":[\"uvi\",\"uvvi\",\"uvg\",\"uvvg\"]},\"image/vnd.djvu\":{\"source\":\"iana\",\"extensions\":[\"djvu\",\"djv\"]},\"image/vnd.dvb.subtitle\":{\"source\":\"iana\",\"extensions\":[\"sub\"]},\"image/vnd.dwg\":{\"source\":\"iana\",\"extensions\":[\"dwg\"]},\"image/vnd.dxf\":{\"source\":\"iana\",\"extensions\":[\"dxf\"]},\"image/vnd.fastbidsheet\":{\"source\":\"iana\",\"extensions\":[\"fbs\"]},\"image/vnd.fpx\":{\"source\":\"iana\",\"extensions\":[\"fpx\"]},\"image/vnd.fst\":{\"source\":\"iana\",\"extensions\":[\"fst\"]},\"image/vnd.fujixerox.edmics-mmr\":{\"source\":\"iana\",\"extensions\":[\"mmr\"]},\"image/vnd.fujixerox.edmics-rlc\":{\"source\":\"iana\",\"extensions\":[\"rlc\"]},\"image/vnd.globalgraphics.pgb\":{\"source\":\"iana\"},\"image/vnd.microsoft.icon\":{\"source\":\"iana\",\"extensions\":[\"ico\"]},\"image/vnd.mix\":{\"source\":\"iana\"},\"image/vnd.mozilla.apng\":{\"source\":\"iana\"},\"image/vnd.ms-dds\":{\"extensions\":[\"dds\"]},\"image/vnd.ms-modi\":{\"source\":\"iana\",\"extensions\":[\"mdi\"]},\"image/vnd.ms-photo\":{\"source\":\"apache\",\"extensions\":[\"wdp\"]},\"image/vnd.net-fpx\":{\"source\":\"iana\",\"extensions\":[\"npx\"]},\"image/vnd.radiance\":{\"source\":\"iana\"},\"image/vnd.sealed.png\":{\"source\":\"iana\"},\"image/vnd.sealedmedia.softseal.gif\":{\"source\":\"iana\"},\"image/vnd.sealedmedia.softseal.jpg\":{\"source\":\"iana\"},\"image/vnd.svf\":{\"source\":\"iana\"},\"image/vnd.tencent.tap\":{\"source\":\"iana\",\"extensions\":[\"tap\"]},\"image/vnd.valve.source.texture\":{\"source\":\"iana\",\"extensions\":[\"vtf\"]},\"image/vnd.wap.wbmp\":{\"source\":\"iana\",\"extensions\":[\"wbmp\"]},\"image/vnd.xiff\":{\"source\":\"iana\",\"extensions\":[\"xif\"]},\"image/vnd.zbrush.pcx\":{\"source\":\"iana\",\"extensions\":[\"pcx\"]},\"image/webp\":{\"source\":\"apache\",\"extensions\":[\"webp\"]},\"image/wmf\":{\"source\":\"iana\",\"extensions\":[\"wmf\"]},\"image/x-3ds\":{\"source\":\"apache\",\"extensions\":[\"3ds\"]},\"image/x-cmu-raster\":{\"source\":\"apache\",\"extensions\":[\"ras\"]},\"image/x-cmx\":{\"source\":\"apache\",\"extensions\":[\"cmx\"]},\"image/x-freehand\":{\"source\":\"apache\",\"extensions\":[\"fh\",\"fhc\",\"fh4\",\"fh5\",\"fh7\"]},\"image/x-icon\":{\"source\":\"apache\",\"compressible\":true,\"extensions\":[\"ico\"]},\"image/x-jng\":{\"source\":\"nginx\",\"extensions\":[\"jng\"]},\"image/x-mrsid-image\":{\"source\":\"apache\",\"extensions\":[\"sid\"]},\"image/x-ms-bmp\":{\"source\":\"nginx\",\"compressible\":true,\"extensions\":[\"bmp\"]},\"image/x-pcx\":{\"source\":\"apache\",\"extensions\":[\"pcx\"]},\"image/x-pict\":{\"source\":\"apache\",\"extensions\":[\"pic\",\"pct\"]},\"image/x-portable-anymap\":{\"source\":\"apache\",\"extensions\":[\"pnm\"]},\"image/x-portable-bitmap\":{\"source\":\"apache\",\"extensions\":[\"pbm\"]},\"image/x-portable-graymap\":{\"source\":\"apache\",\"extensions\":[\"pgm\"]},\"image/x-portable-pixmap\":{\"source\":\"apache\",\"extensions\":[\"ppm\"]},\"image/x-rgb\":{\"source\":\"apache\",\"extensions\":[\"rgb\"]},\"image/x-tga\":{\"source\":\"apache\",\"extensions\":[\"tga\"]},\"image/x-xbitmap\":{\"source\":\"apache\",\"extensions\":[\"xbm\"]},\"image/x-xcf\":{\"compressible\":false},\"image/x-xpixmap\":{\"source\":\"apache\",\"extensions\":[\"xpm\"]},\"image/x-xwindowdump\":{\"source\":\"apache\",\"extensions\":[\"xwd\"]},\"message/cpim\":{\"source\":\"iana\"},\"message/delivery-status\":{\"source\":\"iana\"},\"message/disposition-notification\":{\"source\":\"iana\",\"extensions\":[\"disposition-notification\"]},\"message/external-body\":{\"source\":\"iana\"},\"message/feedback-report\":{\"source\":\"iana\"},\"message/global\":{\"source\":\"iana\",\"extensions\":[\"u8msg\"]},\"message/global-delivery-status\":{\"source\":\"iana\",\"extensions\":[\"u8dsn\"]},\"message/global-disposition-notification\":{\"source\":\"iana\",\"extensions\":[\"u8mdn\"]},\"message/global-headers\":{\"source\":\"iana\",\"extensions\":[\"u8hdr\"]},\"message/http\":{\"source\":\"iana\",\"compressible\":false},\"message/imdn+xml\":{\"source\":\"iana\",\"compressible\":true},\"message/news\":{\"source\":\"iana\"},\"message/partial\":{\"source\":\"iana\",\"compressible\":false},\"message/rfc822\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"eml\",\"mime\"]},\"message/s-http\":{\"source\":\"iana\"},\"message/sip\":{\"source\":\"iana\"},\"message/sipfrag\":{\"source\":\"iana\"},\"message/tracking-status\":{\"source\":\"iana\"},\"message/vnd.si.simp\":{\"source\":\"iana\"},\"message/vnd.wfa.wsc\":{\"source\":\"iana\",\"extensions\":[\"wsc\"]},\"model/3mf\":{\"source\":\"iana\",\"extensions\":[\"3mf\"]},\"model/gltf+json\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"gltf\"]},\"model/gltf-binary\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"glb\"]},\"model/iges\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"igs\",\"iges\"]},\"model/mesh\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"msh\",\"mesh\",\"silo\"]},\"model/mtl\":{\"source\":\"iana\",\"extensions\":[\"mtl\"]},\"model/obj\":{\"source\":\"iana\",\"extensions\":[\"obj\"]},\"model/stl\":{\"source\":\"iana\",\"extensions\":[\"stl\"]},\"model/vnd.collada+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"dae\"]},\"model/vnd.dwf\":{\"source\":\"iana\",\"extensions\":[\"dwf\"]},\"model/vnd.flatland.3dml\":{\"source\":\"iana\"},\"model/vnd.gdl\":{\"source\":\"iana\",\"extensions\":[\"gdl\"]},\"model/vnd.gs-gdl\":{\"source\":\"apache\"},\"model/vnd.gs.gdl\":{\"source\":\"iana\"},\"model/vnd.gtw\":{\"source\":\"iana\",\"extensions\":[\"gtw\"]},\"model/vnd.moml+xml\":{\"source\":\"iana\",\"compressible\":true},\"model/vnd.mts\":{\"source\":\"iana\",\"extensions\":[\"mts\"]},\"model/vnd.opengex\":{\"source\":\"iana\",\"extensions\":[\"ogex\"]},\"model/vnd.parasolid.transmit.binary\":{\"source\":\"iana\",\"extensions\":[\"x_b\"]},\"model/vnd.parasolid.transmit.text\":{\"source\":\"iana\",\"extensions\":[\"x_t\"]},\"model/vnd.rosette.annotated-data-model\":{\"source\":\"iana\"},\"model/vnd.usdz+zip\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"usdz\"]},\"model/vnd.valve.source.compiled-map\":{\"source\":\"iana\",\"extensions\":[\"bsp\"]},\"model/vnd.vtu\":{\"source\":\"iana\",\"extensions\":[\"vtu\"]},\"model/vrml\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"wrl\",\"vrml\"]},\"model/x3d+binary\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"x3db\",\"x3dbz\"]},\"model/x3d+fastinfoset\":{\"source\":\"iana\",\"extensions\":[\"x3db\"]},\"model/x3d+vrml\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"x3dv\",\"x3dvz\"]},\"model/x3d+xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"x3d\",\"x3dz\"]},\"model/x3d-vrml\":{\"source\":\"iana\",\"extensions\":[\"x3dv\"]},\"multipart/alternative\":{\"source\":\"iana\",\"compressible\":false},\"multipart/appledouble\":{\"source\":\"iana\"},\"multipart/byteranges\":{\"source\":\"iana\"},\"multipart/digest\":{\"source\":\"iana\"},\"multipart/encrypted\":{\"source\":\"iana\",\"compressible\":false},\"multipart/form-data\":{\"source\":\"iana\",\"compressible\":false},\"multipart/header-set\":{\"source\":\"iana\"},\"multipart/mixed\":{\"source\":\"iana\"},\"multipart/multilingual\":{\"source\":\"iana\"},\"multipart/parallel\":{\"source\":\"iana\"},\"multipart/related\":{\"source\":\"iana\",\"compressible\":false},\"multipart/report\":{\"source\":\"iana\"},\"multipart/signed\":{\"source\":\"iana\",\"compressible\":false},\"multipart/vnd.bint.med-plus\":{\"source\":\"iana\"},\"multipart/voice-message\":{\"source\":\"iana\"},\"multipart/x-mixed-replace\":{\"source\":\"iana\"},\"text/1d-interleaved-parityfec\":{\"source\":\"iana\"},\"text/cache-manifest\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"appcache\",\"manifest\"]},\"text/calendar\":{\"source\":\"iana\",\"extensions\":[\"ics\",\"ifb\"]},\"text/calender\":{\"compressible\":true},\"text/cmd\":{\"compressible\":true},\"text/coffeescript\":{\"extensions\":[\"coffee\",\"litcoffee\"]},\"text/css\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true,\"extensions\":[\"css\"]},\"text/csv\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"csv\"]},\"text/csv-schema\":{\"source\":\"iana\"},\"text/directory\":{\"source\":\"iana\"},\"text/dns\":{\"source\":\"iana\"},\"text/ecmascript\":{\"source\":\"iana\"},\"text/encaprtp\":{\"source\":\"iana\"},\"text/enriched\":{\"source\":\"iana\"},\"text/flexfec\":{\"source\":\"iana\"},\"text/fwdred\":{\"source\":\"iana\"},\"text/grammar-ref-list\":{\"source\":\"iana\"},\"text/html\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"html\",\"htm\",\"shtml\"]},\"text/jade\":{\"extensions\":[\"jade\"]},\"text/javascript\":{\"source\":\"iana\",\"compressible\":true},\"text/jcr-cnd\":{\"source\":\"iana\"},\"text/jsx\":{\"compressible\":true,\"extensions\":[\"jsx\"]},\"text/less\":{\"compressible\":true,\"extensions\":[\"less\"]},\"text/markdown\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"markdown\",\"md\"]},\"text/mathml\":{\"source\":\"nginx\",\"extensions\":[\"mml\"]},\"text/mdx\":{\"compressible\":true,\"extensions\":[\"mdx\"]},\"text/mizar\":{\"source\":\"iana\"},\"text/n3\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true,\"extensions\":[\"n3\"]},\"text/parameters\":{\"source\":\"iana\",\"charset\":\"UTF-8\"},\"text/parityfec\":{\"source\":\"iana\"},\"text/plain\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"txt\",\"text\",\"conf\",\"def\",\"list\",\"log\",\"in\",\"ini\"]},\"text/provenance-notation\":{\"source\":\"iana\",\"charset\":\"UTF-8\"},\"text/prs.fallenstein.rst\":{\"source\":\"iana\"},\"text/prs.lines.tag\":{\"source\":\"iana\",\"extensions\":[\"dsc\"]},\"text/prs.prop.logic\":{\"source\":\"iana\"},\"text/raptorfec\":{\"source\":\"iana\"},\"text/red\":{\"source\":\"iana\"},\"text/rfc822-headers\":{\"source\":\"iana\"},\"text/richtext\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"rtx\"]},\"text/rtf\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"rtf\"]},\"text/rtp-enc-aescm128\":{\"source\":\"iana\"},\"text/rtploopback\":{\"source\":\"iana\"},\"text/rtx\":{\"source\":\"iana\"},\"text/sgml\":{\"source\":\"iana\",\"extensions\":[\"sgml\",\"sgm\"]},\"text/shex\":{\"extensions\":[\"shex\"]},\"text/slim\":{\"extensions\":[\"slim\",\"slm\"]},\"text/strings\":{\"source\":\"iana\"},\"text/stylus\":{\"extensions\":[\"stylus\",\"styl\"]},\"text/t140\":{\"source\":\"iana\"},\"text/tab-separated-values\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"tsv\"]},\"text/troff\":{\"source\":\"iana\",\"extensions\":[\"t\",\"tr\",\"roff\",\"man\",\"me\",\"ms\"]},\"text/turtle\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"extensions\":[\"ttl\"]},\"text/ulpfec\":{\"source\":\"iana\"},\"text/uri-list\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"uri\",\"uris\",\"urls\"]},\"text/vcard\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"vcard\"]},\"text/vnd.a\":{\"source\":\"iana\"},\"text/vnd.abc\":{\"source\":\"iana\"},\"text/vnd.ascii-art\":{\"source\":\"iana\"},\"text/vnd.curl\":{\"source\":\"iana\",\"extensions\":[\"curl\"]},\"text/vnd.curl.dcurl\":{\"source\":\"apache\",\"extensions\":[\"dcurl\"]},\"text/vnd.curl.mcurl\":{\"source\":\"apache\",\"extensions\":[\"mcurl\"]},\"text/vnd.curl.scurl\":{\"source\":\"apache\",\"extensions\":[\"scurl\"]},\"text/vnd.debian.copyright\":{\"source\":\"iana\",\"charset\":\"UTF-8\"},\"text/vnd.dmclientscript\":{\"source\":\"iana\"},\"text/vnd.dvb.subtitle\":{\"source\":\"iana\",\"extensions\":[\"sub\"]},\"text/vnd.esmertec.theme-descriptor\":{\"source\":\"iana\",\"charset\":\"UTF-8\"},\"text/vnd.ficlab.flt\":{\"source\":\"iana\"},\"text/vnd.fly\":{\"source\":\"iana\",\"extensions\":[\"fly\"]},\"text/vnd.fmi.flexstor\":{\"source\":\"iana\",\"extensions\":[\"flx\"]},\"text/vnd.gml\":{\"source\":\"iana\"},\"text/vnd.graphviz\":{\"source\":\"iana\",\"extensions\":[\"gv\"]},\"text/vnd.hgl\":{\"source\":\"iana\"},\"text/vnd.in3d.3dml\":{\"source\":\"iana\",\"extensions\":[\"3dml\"]},\"text/vnd.in3d.spot\":{\"source\":\"iana\",\"extensions\":[\"spot\"]},\"text/vnd.iptc.newsml\":{\"source\":\"iana\"},\"text/vnd.iptc.nitf\":{\"source\":\"iana\"},\"text/vnd.latex-z\":{\"source\":\"iana\"},\"text/vnd.motorola.reflex\":{\"source\":\"iana\"},\"text/vnd.ms-mediapackage\":{\"source\":\"iana\"},\"text/vnd.net2phone.commcenter.command\":{\"source\":\"iana\"},\"text/vnd.radisys.msml-basic-layout\":{\"source\":\"iana\"},\"text/vnd.senx.warpscript\":{\"source\":\"iana\"},\"text/vnd.si.uricatalogue\":{\"source\":\"iana\"},\"text/vnd.sosi\":{\"source\":\"iana\"},\"text/vnd.sun.j2me.app-descriptor\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"extensions\":[\"jad\"]},\"text/vnd.trolltech.linguist\":{\"source\":\"iana\",\"charset\":\"UTF-8\"},\"text/vnd.wap.si\":{\"source\":\"iana\"},\"text/vnd.wap.sl\":{\"source\":\"iana\"},\"text/vnd.wap.wml\":{\"source\":\"iana\",\"extensions\":[\"wml\"]},\"text/vnd.wap.wmlscript\":{\"source\":\"iana\",\"extensions\":[\"wmls\"]},\"text/vtt\":{\"source\":\"iana\",\"charset\":\"UTF-8\",\"compressible\":true,\"extensions\":[\"vtt\"]},\"text/x-asm\":{\"source\":\"apache\",\"extensions\":[\"s\",\"asm\"]},\"text/x-c\":{\"source\":\"apache\",\"extensions\":[\"c\",\"cc\",\"cxx\",\"cpp\",\"h\",\"hh\",\"dic\"]},\"text/x-component\":{\"source\":\"nginx\",\"extensions\":[\"htc\"]},\"text/x-fortran\":{\"source\":\"apache\",\"extensions\":[\"f\",\"for\",\"f77\",\"f90\"]},\"text/x-gwt-rpc\":{\"compressible\":true},\"text/x-handlebars-template\":{\"extensions\":[\"hbs\"]},\"text/x-java-source\":{\"source\":\"apache\",\"extensions\":[\"java\"]},\"text/x-jquery-tmpl\":{\"compressible\":true},\"text/x-lua\":{\"extensions\":[\"lua\"]},\"text/x-markdown\":{\"compressible\":true,\"extensions\":[\"mkd\"]},\"text/x-nfo\":{\"source\":\"apache\",\"extensions\":[\"nfo\"]},\"text/x-opml\":{\"source\":\"apache\",\"extensions\":[\"opml\"]},\"text/x-org\":{\"compressible\":true,\"extensions\":[\"org\"]},\"text/x-pascal\":{\"source\":\"apache\",\"extensions\":[\"p\",\"pas\"]},\"text/x-processing\":{\"compressible\":true,\"extensions\":[\"pde\"]},\"text/x-sass\":{\"extensions\":[\"sass\"]},\"text/x-scss\":{\"extensions\":[\"scss\"]},\"text/x-setext\":{\"source\":\"apache\",\"extensions\":[\"etx\"]},\"text/x-sfv\":{\"source\":\"apache\",\"extensions\":[\"sfv\"]},\"text/x-suse-ymp\":{\"compressible\":true,\"extensions\":[\"ymp\"]},\"text/x-uuencode\":{\"source\":\"apache\",\"extensions\":[\"uu\"]},\"text/x-vcalendar\":{\"source\":\"apache\",\"extensions\":[\"vcs\"]},\"text/x-vcard\":{\"source\":\"apache\",\"extensions\":[\"vcf\"]},\"text/xml\":{\"source\":\"iana\",\"compressible\":true,\"extensions\":[\"xml\"]},\"text/xml-external-parsed-entity\":{\"source\":\"iana\"},\"text/yaml\":{\"extensions\":[\"yaml\",\"yml\"]},\"video/1d-interleaved-parityfec\":{\"source\":\"iana\"},\"video/3gpp\":{\"source\":\"iana\",\"extensions\":[\"3gp\",\"3gpp\"]},\"video/3gpp-tt\":{\"source\":\"iana\"},\"video/3gpp2\":{\"source\":\"iana\",\"extensions\":[\"3g2\"]},\"video/bmpeg\":{\"source\":\"iana\"},\"video/bt656\":{\"source\":\"iana\"},\"video/celb\":{\"source\":\"iana\"},\"video/dv\":{\"source\":\"iana\"},\"video/encaprtp\":{\"source\":\"iana\"},\"video/flexfec\":{\"source\":\"iana\"},\"video/h261\":{\"source\":\"iana\",\"extensions\":[\"h261\"]},\"video/h263\":{\"source\":\"iana\",\"extensions\":[\"h263\"]},\"video/h263-1998\":{\"source\":\"iana\"},\"video/h263-2000\":{\"source\":\"iana\"},\"video/h264\":{\"source\":\"iana\",\"extensions\":[\"h264\"]},\"video/h264-rcdo\":{\"source\":\"iana\"},\"video/h264-svc\":{\"source\":\"iana\"},\"video/h265\":{\"source\":\"iana\"},\"video/iso.segment\":{\"source\":\"iana\"},\"video/jpeg\":{\"source\":\"iana\",\"extensions\":[\"jpgv\"]},\"video/jpeg2000\":{\"source\":\"iana\"},\"video/jpm\":{\"source\":\"apache\",\"extensions\":[\"jpm\",\"jpgm\"]},\"video/mj2\":{\"source\":\"iana\",\"extensions\":[\"mj2\",\"mjp2\"]},\"video/mp1s\":{\"source\":\"iana\"},\"video/mp2p\":{\"source\":\"iana\"},\"video/mp2t\":{\"source\":\"iana\",\"extensions\":[\"ts\"]},\"video/mp4\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"mp4\",\"mp4v\",\"mpg4\"]},\"video/mp4v-es\":{\"source\":\"iana\"},\"video/mpeg\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"mpeg\",\"mpg\",\"mpe\",\"m1v\",\"m2v\"]},\"video/mpeg4-generic\":{\"source\":\"iana\"},\"video/mpv\":{\"source\":\"iana\"},\"video/nv\":{\"source\":\"iana\"},\"video/ogg\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"ogv\"]},\"video/parityfec\":{\"source\":\"iana\"},\"video/pointer\":{\"source\":\"iana\"},\"video/quicktime\":{\"source\":\"iana\",\"compressible\":false,\"extensions\":[\"qt\",\"mov\"]},\"video/raptorfec\":{\"source\":\"iana\"},\"video/raw\":{\"source\":\"iana\"},\"video/rtp-enc-aescm128\":{\"source\":\"iana\"},\"video/rtploopback\":{\"source\":\"iana\"},\"video/rtx\":{\"source\":\"iana\"},\"video/smpte291\":{\"source\":\"iana\"},\"video/smpte292m\":{\"source\":\"iana\"},\"video/ulpfec\":{\"source\":\"iana\"},\"video/vc1\":{\"source\":\"iana\"},\"video/vc2\":{\"source\":\"iana\"},\"video/vnd.cctv\":{\"source\":\"iana\"},\"video/vnd.dece.hd\":{\"source\":\"iana\",\"extensions\":[\"uvh\",\"uvvh\"]},\"video/vnd.dece.mobile\":{\"source\":\"iana\",\"extensions\":[\"uvm\",\"uvvm\"]},\"video/vnd.dece.mp4\":{\"source\":\"iana\"},\"video/vnd.dece.pd\":{\"source\":\"iana\",\"extensions\":[\"uvp\",\"uvvp\"]},\"video/vnd.dece.sd\":{\"source\":\"iana\",\"extensions\":[\"uvs\",\"uvvs\"]},\"video/vnd.dece.video\":{\"source\":\"iana\",\"extensions\":[\"uvv\",\"uvvv\"]},\"video/vnd.directv.mpeg\":{\"source\":\"iana\"},\"video/vnd.directv.mpeg-tts\":{\"source\":\"iana\"},\"video/vnd.dlna.mpeg-tts\":{\"source\":\"iana\"},\"video/vnd.dvb.file\":{\"source\":\"iana\",\"extensions\":[\"dvb\"]},\"video/vnd.fvt\":{\"source\":\"iana\",\"extensions\":[\"fvt\"]},\"video/vnd.hns.video\":{\"source\":\"iana\"},\"video/vnd.iptvforum.1dparityfec-1010\":{\"source\":\"iana\"},\"video/vnd.iptvforum.1dparityfec-2005\":{\"source\":\"iana\"},\"video/vnd.iptvforum.2dparityfec-1010\":{\"source\":\"iana\"},\"video/vnd.iptvforum.2dparityfec-2005\":{\"source\":\"iana\"},\"video/vnd.iptvforum.ttsavc\":{\"source\":\"iana\"},\"video/vnd.iptvforum.ttsmpeg2\":{\"source\":\"iana\"},\"video/vnd.motorola.video\":{\"source\":\"iana\"},\"video/vnd.motorola.videop\":{\"source\":\"iana\"},\"video/vnd.mpegurl\":{\"source\":\"iana\",\"extensions\":[\"mxu\",\"m4u\"]},\"video/vnd.ms-playready.media.pyv\":{\"source\":\"iana\",\"extensions\":[\"pyv\"]},\"video/vnd.nokia.interleaved-multimedia\":{\"source\":\"iana\"},\"video/vnd.nokia.mp4vr\":{\"source\":\"iana\"},\"video/vnd.nokia.videovoip\":{\"source\":\"iana\"},\"video/vnd.objectvideo\":{\"source\":\"iana\"},\"video/vnd.radgamettools.bink\":{\"source\":\"iana\"},\"video/vnd.radgamettools.smacker\":{\"source\":\"iana\"},\"video/vnd.sealed.mpeg1\":{\"source\":\"iana\"},\"video/vnd.sealed.mpeg4\":{\"source\":\"iana\"},\"video/vnd.sealed.swf\":{\"source\":\"iana\"},\"video/vnd.sealedmedia.softseal.mov\":{\"source\":\"iana\"},\"video/vnd.uvvu.mp4\":{\"source\":\"iana\",\"extensions\":[\"uvu\",\"uvvu\"]},\"video/vnd.vivo\":{\"source\":\"iana\",\"extensions\":[\"viv\"]},\"video/vnd.youtube.yt\":{\"source\":\"iana\"},\"video/vp8\":{\"source\":\"iana\"},\"video/webm\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"webm\"]},\"video/x-f4v\":{\"source\":\"apache\",\"extensions\":[\"f4v\"]},\"video/x-fli\":{\"source\":\"apache\",\"extensions\":[\"fli\"]},\"video/x-flv\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"flv\"]},\"video/x-m4v\":{\"source\":\"apache\",\"extensions\":[\"m4v\"]},\"video/x-matroska\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"mkv\",\"mk3d\",\"mks\"]},\"video/x-mng\":{\"source\":\"apache\",\"extensions\":[\"mng\"]},\"video/x-ms-asf\":{\"source\":\"apache\",\"extensions\":[\"asf\",\"asx\"]},\"video/x-ms-vob\":{\"source\":\"apache\",\"extensions\":[\"vob\"]},\"video/x-ms-wm\":{\"source\":\"apache\",\"extensions\":[\"wm\"]},\"video/x-ms-wmv\":{\"source\":\"apache\",\"compressible\":false,\"extensions\":[\"wmv\"]},\"video/x-ms-wmx\":{\"source\":\"apache\",\"extensions\":[\"wmx\"]},\"video/x-ms-wvx\":{\"source\":\"apache\",\"extensions\":[\"wvx\"]},\"video/x-msvideo\":{\"source\":\"apache\",\"extensions\":[\"avi\"]},\"video/x-sgi-movie\":{\"source\":\"apache\",\"extensions\":[\"movie\"]},\"video/x-smv\":{\"source\":\"apache\",\"extensions\":[\"smv\"]},\"x-conference/x-cooltalk\":{\"source\":\"apache\",\"extensions\":[\"ice\"]},\"x-shader/x-fragment\":{\"compressible\":true},\"x-shader/x-vertex\":{\"compressible\":true}}"); + +/***/ }), + +/***/ "../../node_modules/mime-db/index.js": +/***/ (function(module, exports, __webpack_require__) { + +/*! + * mime-db + * Copyright(c) 2014 Jonathan Ong + * MIT Licensed + */ + +/** + * Module exports. + */ + +module.exports = __webpack_require__("../../node_modules/mime-db/db.json") + + +/***/ }), + +/***/ "../../node_modules/mime-types/index.js": +/***/ (function(module, exports, __webpack_require__) { + +"use strict"; +/*! + * mime-types + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */ + + + +/** + * Module dependencies. + * @private + */ + +var db = __webpack_require__("../../node_modules/mime-db/index.js") +var extname = __webpack_require__("path").extname + +/** + * Module variables. + * @private + */ + +var EXTRACT_TYPE_REGEXP = /^\s*([^;\s]*)(?:;|\s|$)/ +var TEXT_TYPE_REGEXP = /^text\//i + +/** + * Module exports. + * @public + */ + +exports.charset = charset +exports.charsets = { lookup: charset } +exports.contentType = contentType +exports.extension = extension +exports.extensions = Object.create(null) +exports.lookup = lookup +exports.types = Object.create(null) + +// Populate the extensions/types maps +populateMaps(exports.extensions, exports.types) + +/** + * Get the default charset for a MIME type. + * + * @param {string} type + * @return {boolean|string} + */ + +function charset (type) { + if (!type || typeof type !== 'string') { + return false + } + + // TODO: use media-typer + var match = EXTRACT_TYPE_REGEXP.exec(type) + var mime = match && db[match[1].toLowerCase()] + + if (mime && mime.charset) { + return mime.charset + } + + // default text/* to utf-8 + if (match && TEXT_TYPE_REGEXP.test(match[1])) { + return 'UTF-8' + } + + return false +} + +/** + * Create a full Content-Type header given a MIME type or extension. + * + * @param {string} str + * @return {boolean|string} + */ + +function contentType (str) { + // TODO: should this even be in this module? + if (!str || typeof str !== 'string') { + return false + } + + var mime = str.indexOf('/') === -1 + ? exports.lookup(str) + : str + + if (!mime) { + return false + } + + // TODO: use content-type or other module + if (mime.indexOf('charset') === -1) { + var charset = exports.charset(mime) + if (charset) mime += '; charset=' + charset.toLowerCase() + } + + return mime +} + +/** + * Get the default extension for a MIME type. + * + * @param {string} type + * @return {boolean|string} + */ + +function extension (type) { + if (!type || typeof type !== 'string') { + return false + } + + // TODO: use media-typer + var match = EXTRACT_TYPE_REGEXP.exec(type) + + // get extensions + var exts = match && exports.extensions[match[1].toLowerCase()] + + if (!exts || !exts.length) { + return false + } + + return exts[0] +} + +/** + * Lookup the MIME type for a file path/extension. + * + * @param {string} path + * @return {boolean|string} + */ + +function lookup (path) { + if (!path || typeof path !== 'string') { + return false + } + + // get the extension ("ext" or ".ext" or full path) + var extension = extname('x.' + path) + .toLowerCase() + .substr(1) + + if (!extension) { + return false + } + + return exports.types[extension] || false +} + +/** + * Populate the extensions and types maps. + * @private + */ + +function populateMaps (extensions, types) { + // source preference (least -> most) + var preference = ['nginx', 'apache', undefined, 'iana'] + + Object.keys(db).forEach(function forEachMimeType (type) { + var mime = db[type] + var exts = mime.extensions + + if (!exts || !exts.length) { + return + } + + // mime -> extensions + extensions[type] = exts + + // extension -> mime + for (var i = 0; i < exts.length; i++) { + var extension = exts[i] + + if (types[extension]) { + var from = preference.indexOf(db[types[extension]].source) + var to = preference.indexOf(mime.source) + + if (types[extension] !== 'application/octet-stream' && + (from > to || (from === to && types[extension].substr(0, 12) === 'application/'))) { + // skip the remapping + continue + } + } + + // set the extension -> mime + types[extension] = type + } + }) +} + + /***/ }), /***/ "../../node_modules/mimic-fn/index.js": @@ -36304,6 +38461,112 @@ function isConstructorOrProto (obj, key) { } +/***/ }), + +/***/ "../../node_modules/mkdirp/index.js": +/***/ (function(module, exports, __webpack_require__) { + +var path = __webpack_require__("path"); +var fs = __webpack_require__("fs"); +var _0777 = parseInt('0777', 8); + +module.exports = mkdirP.mkdirp = mkdirP.mkdirP = mkdirP; + +function mkdirP (p, opts, f, made) { + if (typeof opts === 'function') { + f = opts; + opts = {}; + } + else if (!opts || typeof opts !== 'object') { + opts = { mode: opts }; + } + + var mode = opts.mode; + var xfs = opts.fs || fs; + + if (mode === undefined) { + mode = _0777 & (~process.umask()); + } + if (!made) made = null; + + var cb = f || function () {}; + p = path.resolve(p); + + xfs.mkdir(p, mode, function (er) { + if (!er) { + made = made || p; + return cb(null, made); + } + switch (er.code) { + case 'ENOENT': + if (path.dirname(p) === p) return cb(er); + mkdirP(path.dirname(p), opts, function (er, made) { + if (er) cb(er, made); + else mkdirP(p, opts, cb, made); + }); + break; + + // In the case of any other error, just see if there's a dir + // there already. If so, then hooray! If not, then something + // is borked. + default: + xfs.stat(p, function (er2, stat) { + // if the stat fails, then that's super weird. + // let the original error be the failure reason. + if (er2 || !stat.isDirectory()) cb(er, made) + else cb(null, made); + }); + break; + } + }); +} + +mkdirP.sync = function sync (p, opts, made) { + if (!opts || typeof opts !== 'object') { + opts = { mode: opts }; + } + + var mode = opts.mode; + var xfs = opts.fs || fs; + + if (mode === undefined) { + mode = _0777 & (~process.umask()); + } + if (!made) made = null; + + p = path.resolve(p); + + try { + xfs.mkdirSync(p, mode); + made = made || p; + } + catch (err0) { + switch (err0.code) { + case 'ENOENT' : + made = sync(path.dirname(p), opts, made); + sync(p, opts, made); + break; + + // In the case of any other error, just see if there's a dir + // there already. If so, then hooray! If not, then something + // is borked. + default: + var stat; + try { + stat = xfs.statSync(p); + } + catch (err1) { + throw err0; + } + if (!stat.isDirectory()) throw err0; + break; + } + } + + return made; +}; + + /***/ }), /***/ "../../node_modules/multimatch/index.js": @@ -60672,17 +62935,6 @@ async function installBazelTools(repoRootPath) { -async function isVaultAvailable() { - try { - await Object(_child_process__WEBPACK_IMPORTED_MODULE_3__[/* spawn */ "a"])('vault', ['--version'], { - stdio: 'pipe' - }); - return true; - } catch { - return false; - } -} - async function isElasticCommitter() { try { const { @@ -60696,21 +62948,13 @@ async function isElasticCommitter() { } } -async function migrateToNewServersIfNeeded(settingsPath) { +async function upToDate(settingsPath) { if (!(await Object(_fs__WEBPACK_IMPORTED_MODULE_5__[/* isFile */ "d"])(settingsPath))) { return false; } const readSettingsFile = await Object(_fs__WEBPACK_IMPORTED_MODULE_5__[/* readFile */ "f"])(settingsPath, 'utf8'); - const newReadSettingsFile = readSettingsFile.replace(/cloud\.buildbuddy\.io/g, 'remote.buildbuddy.io'); - - if (newReadSettingsFile === readSettingsFile) { - return false; - } - - Object(_fs__WEBPACK_IMPORTED_MODULE_5__[/* writeFile */ "i"])(settingsPath, newReadSettingsFile); - _log__WEBPACK_IMPORTED_MODULE_4__[/* log */ "a"].info(`[bazel_tools] upgrade remote cache settings to use new server address`); - return true; + return readSettingsFile.startsWith('# V2 '); } async function setupRemoteCache(repoRootPath) { @@ -60720,52 +62964,19 @@ async function setupRemoteCache(repoRootPath) { } _log__WEBPACK_IMPORTED_MODULE_4__[/* log */ "a"].debug(`[bazel_tools] setting up remote cache settings if necessary`); - const settingsPath = Object(path__WEBPACK_IMPORTED_MODULE_2__["resolve"])(repoRootPath, '.bazelrc.cache'); // Checks if we should upgrade the servers used on .bazelrc.cache - // - // NOTE: this can be removed in the future once everyone is migrated into the new servers + const settingsPath = Object(path__WEBPACK_IMPORTED_MODULE_2__["resolve"])(repoRootPath, '.bazelrc.cache'); // Checks if we should upgrade or install the config file - if (await migrateToNewServersIfNeeded(settingsPath)) { - return; - } - - if (Object(fs__WEBPACK_IMPORTED_MODULE_1__["existsSync"])(settingsPath)) { - _log__WEBPACK_IMPORTED_MODULE_4__[/* log */ "a"].debug(`[bazel_tools] remote cache settings already exist, skipping`); - return; - } - - if (!(await isVaultAvailable())) { - _log__WEBPACK_IMPORTED_MODULE_4__[/* log */ "a"].info('[bazel_tools] vault is not available, unable to setup remote cache settings.'); - _log__WEBPACK_IMPORTED_MODULE_4__[/* log */ "a"].info('[bazel_tools] building packages will work, but will be slower in many cases.'); - _log__WEBPACK_IMPORTED_MODULE_4__[/* log */ "a"].info('[bazel_tools] use the following guide or reach out to Operations for assistance'); - _log__WEBPACK_IMPORTED_MODULE_4__[/* log */ "a"].info('[bazel_tools] https://github.com/elastic/infra/tree/master/docs/vault'); - return; - } - - let apiKey = ''; - - try { - const { - stdout - } = await Object(_child_process__WEBPACK_IMPORTED_MODULE_3__[/* spawn */ "a"])('vault', ['read', '-field=readonly-key', 'secret/ui-team/kibana-bazel-remote-cache'], { - stdio: 'pipe' - }); - apiKey = stdout.trim(); - } catch (ex) { - _log__WEBPACK_IMPORTED_MODULE_4__[/* log */ "a"].info('[bazel_tools] unable to read bazel remote cache key from vault, are you authenticated?'); - _log__WEBPACK_IMPORTED_MODULE_4__[/* log */ "a"].info('[bazel_tools] building packages will work, but will be slower in many cases.'); - _log__WEBPACK_IMPORTED_MODULE_4__[/* log */ "a"].info('[bazel_tools] reach out to Operations if you need assistance with this.'); - _log__WEBPACK_IMPORTED_MODULE_4__[/* log */ "a"].info(`[bazel_tools] ${ex}`); + if (await upToDate(settingsPath)) { + _log__WEBPACK_IMPORTED_MODULE_4__[/* log */ "a"].debug(`[bazel_tools] remote cache config already exists and is up-to-date, skipping`); return; } const contents = dedent__WEBPACK_IMPORTED_MODULE_0___default.a` - # V1 - This file is automatically generated by 'yarn kbn bootstrap' + # V2 - This file is automatically generated by 'yarn kbn bootstrap' # To regenerate this file, delete it and run 'yarn kbn bootstrap' again. - build --bes_results_url=https://app.buildbuddy.io/invocation/ - build --bes_backend=grpcs://remote.buildbuddy.io - build --remote_cache=grpcs://remote.buildbuddy.io - build --remote_timeout=3600 - build --remote_header=${apiKey} + build --remote_cache=https://storage.googleapis.com/kibana-local-bazel-remote-cache + build --noremote_upload_local_results + build --incompatible_remote_results_ignore_disk `; Object(fs__WEBPACK_IMPORTED_MODULE_1__["writeFileSync"])(settingsPath, contents); _log__WEBPACK_IMPORTED_MODULE_4__[/* log */ "a"].info(`[bazel_tools] remote cache settings written to ${settingsPath}`); @@ -61563,32 +63774,6 @@ class Project { return this.json.name; } - ensureValidProjectDependency(project) { - const relativePathToProject = normalizePath(path__WEBPACK_IMPORTED_MODULE_1___default.a.relative(this.path, project.path)); - const relativePathToProjectIfBazelPkg = normalizePath(path__WEBPACK_IMPORTED_MODULE_1___default.a.relative(this.path, `${__dirname}/../../../bazel-bin/packages/${path__WEBPACK_IMPORTED_MODULE_1___default.a.basename(project.path)}`)); - const versionInPackageJson = this.allDependencies[project.name]; - const expectedVersionInPackageJson = `link:${relativePathToProject}`; - const expectedVersionInPackageJsonIfBazelPkg = `link:${relativePathToProjectIfBazelPkg}`; // TODO: after introduce bazel to build all the packages and completely remove the support for kbn packages - // do not allow child projects to hold dependencies, unless they are meant to be published externally - - if (versionInPackageJson === expectedVersionInPackageJson || versionInPackageJson === expectedVersionInPackageJsonIfBazelPkg) { - return; - } - - const updateMsg = 'Update its package.json to the expected value below.'; - const meta = { - actual: `"${project.name}": "${versionInPackageJson}"`, - expected: `"${project.name}": "${expectedVersionInPackageJson}" or "${project.name}": "${expectedVersionInPackageJsonIfBazelPkg}"`, - package: `${this.name} (${this.packageJsonLocation})` - }; - - if (Object(_package_json__WEBPACK_IMPORTED_MODULE_5__[/* isLinkDependency */ "a"])(versionInPackageJson)) { - throw new _errors__WEBPACK_IMPORTED_MODULE_3__[/* CliError */ "a"](`[${this.name}] depends on [${project.name}] using 'link:', but the path is wrong. ${updateMsg}`, meta); - } - - throw new _errors__WEBPACK_IMPORTED_MODULE_3__[/* CliError */ "a"](`[${this.name}] depends on [${project.name}] but it's not using the local package. ${updateMsg}`, meta); - } - getBuildConfig() { return this.json.kibana && this.json.kibana.build || {}; } @@ -61660,10 +63845,6 @@ class Project { return Object.values(this.allDependencies).every(dep => Object(_package_json__WEBPACK_IMPORTED_MODULE_5__[/* isLinkDependency */ "a"])(dep)); } -} // We normalize all path separators to `/` in generated files - -function normalizePath(path) { - return path.replace(/[\\\/]+/g, '/'); } /***/ }), @@ -61685,7 +63866,8 @@ function normalizePath(path) { /* harmony import */ var util__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__("util"); /* harmony import */ var util__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(util__WEBPACK_IMPORTED_MODULE_2__); /* harmony import */ var _errors__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__("./src/utils/errors.ts"); -/* harmony import */ var _project__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__("./src/utils/project.ts"); +/* harmony import */ var _log__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__("./src/utils/log.ts"); +/* harmony import */ var _project__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__("./src/utils/project.ts"); /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License @@ -61698,6 +63880,7 @@ function normalizePath(path) { + const glob = Object(util__WEBPACK_IMPORTED_MODULE_2__["promisify"])(glob__WEBPACK_IMPORTED_MODULE_0___default.a); /** a Map of project names to Project instances */ @@ -61716,7 +63899,7 @@ async function getProjects(rootPath, projectsPathsPatterns, { for (const filePath of pathsToProcess) { const projectConfigPath = normalize(filePath); const projectDir = path__WEBPACK_IMPORTED_MODULE_1___default.a.dirname(projectConfigPath); - const project = await _project__WEBPACK_IMPORTED_MODULE_4__[/* Project */ "a"].fromPath(projectDir); + const project = await _project__WEBPACK_IMPORTED_MODULE_5__[/* Project */ "a"].fromPath(projectDir); const excludeProject = exclude.includes(project.name) || include.length > 0 && !include.includes(project.name) || bazelOnly && !project.isBazelPackage(); if (excludeProject) { @@ -61790,10 +63973,18 @@ function buildProjectGraph(projects) { const projectDeps = []; const dependencies = project.allDependencies; + if (!project.isSinglePackageJsonProject && Object.keys(dependencies).length > 0) { + _log__WEBPACK_IMPORTED_MODULE_4__[/* log */ "a"].warning(`${project.name} is not allowed to hold local dependencies and they will be discarded. Please declare them at the root package.json`); + } + + if (!project.isSinglePackageJsonProject) { + projectGraph.set(project.name, projectDeps); + continue; + } + for (const depName of Object.keys(dependencies)) { if (projects.has(depName)) { const dep = projects.get(depName); - project.ensureValidProjectDependency(dep); projectDeps.push(dep); } } diff --git a/packages/kbn-pm/src/__snapshots__/run.test.ts.snap b/packages/kbn-pm/src/__snapshots__/run.test.ts.snap index e5efc9a91522492..28e1b98e0fcd96e 100644 --- a/packages/kbn-pm/src/__snapshots__/run.test.ts.snap +++ b/packages/kbn-pm/src/__snapshots__/run.test.ts.snap @@ -4,14 +4,9 @@ exports[`excludes project if single \`exclude\` filter is specified 1`] = ` Object { "graph": Object { "bar": Array [], - "baz": Array [ - "bar", - ], + "baz": Array [], "kibana": Array [], - "quux": Array [ - "bar", - "baz", - ], + "quux": Array [], "with-additional-projects": Array [], }, "projects": Array [ @@ -42,12 +37,8 @@ Object { exports[`includes only projects specified in multiple \`include\` filters 1`] = ` Object { "graph": Object { - "bar": Array [ - "foo", - ], - "baz": Array [ - "bar", - ], + "bar": Array [], + "baz": Array [], "foo": Array [], }, "projects": Array [ @@ -72,20 +63,13 @@ Object { exports[`passes all found projects to the command if no filter is specified 1`] = ` Object { "graph": Object { - "bar": Array [ - "foo", - ], - "baz": Array [ - "bar", - ], + "bar": Array [], + "baz": Array [], "foo": Array [], "kibana": Array [ "foo", ], - "quux": Array [ - "bar", - "baz", - ], + "quux": Array [], "with-additional-projects": Array [], }, "projects": Array [ diff --git a/packages/kbn-pm/src/utils/__fixtures__/kibana/packages/bar/package.json b/packages/kbn-pm/src/utils/__fixtures__/kibana/packages/bar/package.json index b5eae58393860f2..06a8b8dcc6aa8af 100644 --- a/packages/kbn-pm/src/utils/__fixtures__/kibana/packages/bar/package.json +++ b/packages/kbn-pm/src/utils/__fixtures__/kibana/packages/bar/package.json @@ -1,7 +1,4 @@ { "name": "bar", - "version": "1.0.0", - "dependencies": { - "foo": "link:../foo" - } + "version": "1.0.0" } diff --git a/packages/kbn-pm/src/utils/__fixtures__/plugins/quux/package.json b/packages/kbn-pm/src/utils/__fixtures__/plugins/quux/package.json index b2794986c5b0bc2..f2a30624545092e 100644 --- a/packages/kbn-pm/src/utils/__fixtures__/plugins/quux/package.json +++ b/packages/kbn-pm/src/utils/__fixtures__/plugins/quux/package.json @@ -1,8 +1,4 @@ { "name": "quux", - "version": "1.0.0", - "dependencies": { - "bar": "link:../../kibana/packages/bar", - "baz": "link:../baz" - } + "version": "1.0.0" } diff --git a/packages/kbn-pm/src/utils/__fixtures__/plugins/zorge/package.json b/packages/kbn-pm/src/utils/__fixtures__/plugins/zorge/package.json index 80a27b17661dd10..3f22a1845b66a1b 100644 --- a/packages/kbn-pm/src/utils/__fixtures__/plugins/zorge/package.json +++ b/packages/kbn-pm/src/utils/__fixtures__/plugins/zorge/package.json @@ -1,7 +1,4 @@ { "name": "zorge", - "version": "1.0.0", - "dependencies": { - "foo": "link:../../kibana/packages/foo" - } + "version": "1.0.0" } diff --git a/packages/kbn-pm/src/utils/__snapshots__/project.test.ts.snap b/packages/kbn-pm/src/utils/__snapshots__/project.test.ts.snap index b3bcc402db2a304..d4a4e5ca2345282 100644 --- a/packages/kbn-pm/src/utils/__snapshots__/project.test.ts.snap +++ b/packages/kbn-pm/src/utils/__snapshots__/project.test.ts.snap @@ -1,7 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`#ensureValidProjectDependency using link:, but with wrong path 1`] = `"[kibana] depends on [foo] using 'link:', but the path is wrong. Update its package.json to the expected value below."`; - -exports[`#ensureValidProjectDependency using version instead of link: 1`] = `"[kibana] depends on [foo] but it's not using the local package. Update its package.json to the expected value below."`; - exports[`#getExecutables() throws CliError when bin is something strange 1`] = `"[kibana] has an invalid \\"bin\\" field in its package.json, expected an object or a string"`; diff --git a/packages/kbn-pm/src/utils/__snapshots__/projects.test.ts.snap b/packages/kbn-pm/src/utils/__snapshots__/projects.test.ts.snap index 86ba136c50aa177..a716b9fab4e5b7a 100644 --- a/packages/kbn-pm/src/utils/__snapshots__/projects.test.ts.snap +++ b/packages/kbn-pm/src/utils/__snapshots__/projects.test.ts.snap @@ -2,37 +2,28 @@ exports[`#buildProjectGraph builds full project graph 1`] = ` Object { - "bar": Array [ - "foo", - ], + "bar": Array [], "baz": Array [], "foo": Array [], "kibana": Array [ "foo", ], - "quux": Array [ - "bar", - "baz", - ], - "zorge": Array [ - "foo", - ], + "quux": Array [], + "zorge": Array [], } `; exports[`#topologicallyBatchProjects batches projects topologically based on their project dependencies 1`] = ` Array [ Array [ + "bar", "foo", "baz", - ], - Array [ - "kibana", - "bar", + "quux", "zorge", ], Array [ - "quux", + "kibana", ], ] `; @@ -43,10 +34,8 @@ Array [ "kibana", "bar", "baz", - "zorge", - ], - Array [ "quux", + "zorge", ], ] `; diff --git a/packages/kbn-pm/src/utils/bazel/setup_remote_cache.ts b/packages/kbn-pm/src/utils/bazel/setup_remote_cache.ts index 0c5213e2dacc667..5c28d6550d0f7b1 100644 --- a/packages/kbn-pm/src/utils/bazel/setup_remote_cache.ts +++ b/packages/kbn-pm/src/utils/bazel/setup_remote_cache.ts @@ -6,21 +6,11 @@ * Side Public License, v 1. */ import dedent from 'dedent'; -import { existsSync, writeFileSync } from 'fs'; +import { writeFileSync } from 'fs'; import { resolve } from 'path'; import { spawn } from '../child_process'; import { log } from '../log'; -import { isFile, readFile, writeFile } from '../fs'; - -async function isVaultAvailable() { - try { - await spawn('vault', ['--version'], { stdio: 'pipe' }); - - return true; - } catch { - return false; - } -} +import { isFile, readFile } from '../fs'; async function isElasticCommitter() { try { @@ -34,24 +24,13 @@ async function isElasticCommitter() { } } -async function migrateToNewServersIfNeeded(settingsPath: string) { +async function upToDate(settingsPath: string) { if (!(await isFile(settingsPath))) { return false; } const readSettingsFile = await readFile(settingsPath, 'utf8'); - const newReadSettingsFile = readSettingsFile.replace( - /cloud\.buildbuddy\.io/g, - 'remote.buildbuddy.io' - ); - - if (newReadSettingsFile === readSettingsFile) { - return false; - } - - writeFile(settingsPath, newReadSettingsFile); - log.info(`[bazel_tools] upgrade remote cache settings to use new server address`); - return true; + return readSettingsFile.startsWith('# V2 '); } export async function setupRemoteCache(repoRootPath: string) { @@ -64,56 +43,18 @@ export async function setupRemoteCache(repoRootPath: string) { const settingsPath = resolve(repoRootPath, '.bazelrc.cache'); - // Checks if we should upgrade the servers used on .bazelrc.cache - // - // NOTE: this can be removed in the future once everyone is migrated into the new servers - if (await migrateToNewServersIfNeeded(settingsPath)) { - return; - } - - if (existsSync(settingsPath)) { - log.debug(`[bazel_tools] remote cache settings already exist, skipping`); - return; - } - - if (!(await isVaultAvailable())) { - log.info('[bazel_tools] vault is not available, unable to setup remote cache settings.'); - log.info('[bazel_tools] building packages will work, but will be slower in many cases.'); - log.info('[bazel_tools] use the following guide or reach out to Operations for assistance'); - log.info('[bazel_tools] https://github.com/elastic/infra/tree/master/docs/vault'); - return; - } - - let apiKey = ''; - - try { - const { stdout } = await spawn( - 'vault', - ['read', '-field=readonly-key', 'secret/ui-team/kibana-bazel-remote-cache'], - { - stdio: 'pipe', - } - ); - apiKey = stdout.trim(); - } catch (ex: unknown) { - log.info( - '[bazel_tools] unable to read bazel remote cache key from vault, are you authenticated?' - ); - log.info('[bazel_tools] building packages will work, but will be slower in many cases.'); - log.info('[bazel_tools] reach out to Operations if you need assistance with this.'); - log.info(`[bazel_tools] ${ex}`); - + // Checks if we should upgrade or install the config file + if (await upToDate(settingsPath)) { + log.debug(`[bazel_tools] remote cache config already exists and is up-to-date, skipping`); return; } const contents = dedent` - # V1 - This file is automatically generated by 'yarn kbn bootstrap' + # V2 - This file is automatically generated by 'yarn kbn bootstrap' # To regenerate this file, delete it and run 'yarn kbn bootstrap' again. - build --bes_results_url=https://app.buildbuddy.io/invocation/ - build --bes_backend=grpcs://remote.buildbuddy.io - build --remote_cache=grpcs://remote.buildbuddy.io - build --remote_timeout=3600 - build --remote_header=${apiKey} + build --remote_cache=https://storage.googleapis.com/kibana-local-bazel-remote-cache + build --noremote_upload_local_results + build --incompatible_remote_results_ignore_disk `; writeFileSync(settingsPath, contents); diff --git a/packages/kbn-pm/src/utils/project.test.ts b/packages/kbn-pm/src/utils/project.test.ts index 9be59538802838b..389dbf123cd52c5 100644 --- a/packages/kbn-pm/src/utils/project.test.ts +++ b/packages/kbn-pm/src/utils/project.test.ts @@ -50,65 +50,6 @@ test('fields', async () => { expect(kibana.hasScript('build')).toBe(false); }); -describe('#ensureValidProjectDependency', () => { - test('valid link: version', async () => { - const root = createProjectWith({ - dependencies: { - foo: 'link:packages/foo', - }, - }); - - const foo = createProjectWith( - { - name: 'foo', - }, - 'packages/foo' - ); - - expect(() => root.ensureValidProjectDependency(foo)).not.toThrow(); - }); - - test('using link:, but with wrong path', () => { - const root = createProjectWith( - { - dependencies: { - foo: 'link:wrong/path', - }, - }, - rootPath - ); - - const foo = createProjectWith( - { - name: 'foo', - }, - 'packages/foo' - ); - - expect(() => root.ensureValidProjectDependency(foo)).toThrowErrorMatchingSnapshot(); - }); - - test('using version instead of link:', () => { - const root = createProjectWith( - { - dependencies: { - foo: '1.0.0', - }, - }, - rootPath - ); - - const foo = createProjectWith( - { - name: 'foo', - }, - 'packages/foo' - ); - - expect(() => root.ensureValidProjectDependency(foo)).toThrowErrorMatchingSnapshot(); - }); -}); - describe('#getExecutables()', () => { test('converts bin:string to an object with absolute paths', () => { const project = createProjectWith({ diff --git a/packages/kbn-pm/src/utils/project.ts b/packages/kbn-pm/src/utils/project.ts index 48c606c10da42cb..842f82854311667 100644 --- a/packages/kbn-pm/src/utils/project.ts +++ b/packages/kbn-pm/src/utils/project.ts @@ -88,48 +88,6 @@ export class Project { return this.json.name; } - public ensureValidProjectDependency(project: Project) { - const relativePathToProject = normalizePath(Path.relative(this.path, project.path)); - const relativePathToProjectIfBazelPkg = normalizePath( - Path.relative( - this.path, - `${__dirname}/../../../bazel-bin/packages/${Path.basename(project.path)}` - ) - ); - - const versionInPackageJson = this.allDependencies[project.name]; - const expectedVersionInPackageJson = `link:${relativePathToProject}`; - const expectedVersionInPackageJsonIfBazelPkg = `link:${relativePathToProjectIfBazelPkg}`; - - // TODO: after introduce bazel to build all the packages and completely remove the support for kbn packages - // do not allow child projects to hold dependencies, unless they are meant to be published externally - if ( - versionInPackageJson === expectedVersionInPackageJson || - versionInPackageJson === expectedVersionInPackageJsonIfBazelPkg - ) { - return; - } - - const updateMsg = 'Update its package.json to the expected value below.'; - const meta = { - actual: `"${project.name}": "${versionInPackageJson}"`, - expected: `"${project.name}": "${expectedVersionInPackageJson}" or "${project.name}": "${expectedVersionInPackageJsonIfBazelPkg}"`, - package: `${this.name} (${this.packageJsonLocation})`, - }; - - if (isLinkDependency(versionInPackageJson)) { - throw new CliError( - `[${this.name}] depends on [${project.name}] using 'link:', but the path is wrong. ${updateMsg}`, - meta - ); - } - - throw new CliError( - `[${this.name}] depends on [${project.name}] but it's not using the local package. ${updateMsg}`, - meta - ); - } - public getBuildConfig(): BuildConfig { return (this.json.kibana && this.json.kibana.build) || {}; } @@ -206,8 +164,3 @@ export class Project { return Object.values(this.allDependencies).every((dep) => isLinkDependency(dep)); } } - -// We normalize all path separators to `/` in generated files -function normalizePath(path: string) { - return path.replace(/[\\\/]+/g, '/'); -} diff --git a/packages/kbn-pm/src/utils/projects.test.ts b/packages/kbn-pm/src/utils/projects.test.ts index bf7bb052b254ae0..c87876642cf0bd7 100644 --- a/packages/kbn-pm/src/utils/projects.test.ts +++ b/packages/kbn-pm/src/utils/projects.test.ts @@ -249,6 +249,6 @@ describe('#includeTransitiveProjects', () => { const quux = projects.get('quux')!; const withTransitive = includeTransitiveProjects([quux], projects); - expect([...withTransitive.keys()]).toEqual(['quux', 'bar', 'baz', 'foo']); + expect([...withTransitive.keys()]).toEqual(['quux']); }); }); diff --git a/packages/kbn-pm/src/utils/projects.ts b/packages/kbn-pm/src/utils/projects.ts index 28a1fcfec8c3671..e30dfc9f4c87696 100644 --- a/packages/kbn-pm/src/utils/projects.ts +++ b/packages/kbn-pm/src/utils/projects.ts @@ -11,6 +11,7 @@ import path from 'path'; import { promisify } from 'util'; import { CliError } from './errors'; +import { log } from './log'; import { Project } from './project'; const glob = promisify(globSync); @@ -115,14 +116,23 @@ export function buildProjectGraph(projects: ProjectMap) { const projectGraph: ProjectGraph = new Map(); for (const project of projects.values()) { - const projectDeps = []; + const projectDeps: Project[] = []; const dependencies = project.allDependencies; + if (!project.isSinglePackageJsonProject && Object.keys(dependencies).length > 0) { + log.warning( + `${project.name} is not allowed to hold local dependencies and they will be discarded. Please declare them at the root package.json` + ); + } + + if (!project.isSinglePackageJsonProject) { + projectGraph.set(project.name, projectDeps); + continue; + } + for (const depName of Object.keys(dependencies)) { if (projects.has(depName)) { const dep = projects.get(depName)!; - project.ensureValidProjectDependency(dep); - projectDeps.push(dep); } } diff --git a/packages/kbn-shared-ux-components/BUILD.bazel b/packages/kbn-shared-ux-components/BUILD.bazel index 8eca4da01449337..b1420f53760419e 100644 --- a/packages/kbn-shared-ux-components/BUILD.bazel +++ b/packages/kbn-shared-ux-components/BUILD.bazel @@ -40,8 +40,10 @@ NPM_MODULE_EXTRA_FILES = [ # "@npm//name-of-package" # eg. "@npm//lodash" RUNTIME_DEPS = [ - "//packages/kbn-i18n", "//packages/kbn-i18n-react", + "//packages/kbn-i18n", + "//packages/shared-ux/avatar/solution", + "//packages/shared-ux/link/redirect_app", "//packages/kbn-shared-ux-services", "//packages/kbn-shared-ux-storybook", "//packages/kbn-shared-ux-utility", @@ -51,6 +53,7 @@ RUNTIME_DEPS = [ "@npm//classnames", "@npm//react-use", "@npm//react", + "@npm//rxjs", "@npm//url-loader", ] @@ -64,12 +67,14 @@ RUNTIME_DEPS = [ # # References to NPM packages work the same as RUNTIME_DEPS TYPES_DEPS = [ - "//packages/kbn-i18n:npm_module_types", + "//packages/kbn-ambient-ui-types", "//packages/kbn-i18n-react:npm_module_types", + "//packages/kbn-i18n:npm_module_types", + "//packages/shared-ux/avatar/solution:npm_module_types", + "//packages/shared-ux/link/redirect_app:npm_module_types", "//packages/kbn-shared-ux-services:npm_module_types", "//packages/kbn-shared-ux-storybook:npm_module_types", "//packages/kbn-shared-ux-utility:npm_module_types", - "//packages/kbn-ambient-ui-types", "@npm//@types/node", "@npm//@types/jest", "@npm//@types/react", @@ -78,6 +83,7 @@ TYPES_DEPS = [ "@npm//@emotion/css", "@npm//@elastic/eui", "@npm//react-use", + "@npm//rxjs", ] jsts_transpiler( diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.tsx b/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.tsx index bffa7c2a5269c20..3131b6ab2a73c0c 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.tsx +++ b/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.component.tsx @@ -23,8 +23,8 @@ export interface Props { emptyPromptColor?: EuiEmptyPromptProps['color']; } -const createDataViewText = i18n.translate('sharedUXComponents.noDataViewsPage.addDataViewText', { - defaultMessage: 'Create Data View', +const createDataViewText = i18n.translate('sharedUXComponents.noDataViewsPrompt.addDataViewText', { + defaultMessage: 'Create data view', }); // Using raw value because it is content dependent @@ -50,39 +50,55 @@ export const NoDataViews = ({ ); + const title = canCreateNewDataView ? ( +

+ +
+ +

+ ) : ( +

+ +

+ ); + + const body = canCreateNewDataView ? ( +

+ +

+ ) : ( +

+ +

+ ); + return ( } - title={ -

- -
- -

- } - body={ -

- -

- } + title={title} + body={body} actions={createNewButton} footer={dataViewsDocLink && } /> diff --git a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.tsx b/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.tsx index 19fefd87aa88978..8d0e6d93275e188 100644 --- a/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.tsx +++ b/packages/kbn-shared-ux-components/src/empty_state/no_data_views/no_data_views.tsx @@ -61,6 +61,7 @@ export const NoDataViews = ({ onDataViewCreated }: Props) => { onSave: (dataView) => { onDataViewCreated(dataView); }, + showEmptyPrompt: false, }); if (setDataViewEditorRef) { diff --git a/packages/kbn-shared-ux-components/src/index.ts b/packages/kbn-shared-ux-components/src/index.ts index 05afc94f782c87c..77586e8592b6a8e 100644 --- a/packages/kbn-shared-ux-components/src/index.ts +++ b/packages/kbn-shared-ux-components/src/index.ts @@ -15,8 +15,6 @@ export const LazyToolbarButton = React.lazy(() => })) ); -export const RedirectAppLinks = React.lazy(() => import('./redirect_app_links')); - /** * A `ToolbarButton` component that is wrapped by the `withSuspense` HOC. This component can * be used directly by consumers and will load the `LazyToolbarButton` component lazily with @@ -100,23 +98,6 @@ export const KibanaPageTemplateSolutionNavLazy = React.lazy(() => */ export const KibanaPageTemplateSolutionNav = withSuspense(KibanaPageTemplateSolutionNavLazy); -/** - * The Lazily-loaded `KibanaSolutionAvatar` component. Consumers should use `React.Suspense` or - * the withSuspense` HOC to load this component. - */ -export const KibanaSolutionAvatarLazy = React.lazy(() => - import('./solution_avatar').then(({ KibanaSolutionAvatar }) => ({ - default: KibanaSolutionAvatar, - })) -); - -/** - * A `KibanaSolutionAvatar` component that is wrapped by the `withSuspense` HOC. This component can - * be used directly by consumers and will load the `KibanaPageTemplateSolutionNavAvatarLazy` component lazily with - * a predefined fallback and error boundary. - */ -export const KibanaSolutionAvatar = withSuspense(KibanaSolutionAvatarLazy); - /** * The Lazily-loaded `NoDataViews` component. Consumers should use `React.Suspennse` or the * `withSuspense` HOC to load this component. diff --git a/packages/kbn-shared-ux-components/src/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap b/packages/kbn-shared-ux-components/src/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap index 66b085b284391ed..0046e9c3fd3c1df 100644 --- a/packages/kbn-shared-ux-components/src/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap +++ b/packages/kbn-shared-ux-components/src/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap @@ -7,7 +7,7 @@ exports[`NoDataPage render 1`] = ` - - - + `; exports[`ElasticAgentCardComponent props href 1`] = ` - - - + `; exports[`ElasticAgentCardComponent renders 1`] = ` - - - + `; 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" + isDisabled={true} + title={ + + Contact your administrator + } - navigateToUrl={[MockFunction]} -> - - This integration is not yet enabled. Your administrator has the required permissions to turn it on. - - } - image="test-file-stub" - isDisabled={true} - title={ - - Contact your administrator - - } - /> - +/> `; diff --git a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap index 79c0ea245b6cbd8..b15f254a5274aa0 100644 --- a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap +++ b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap @@ -4,7 +4,9 @@ exports[`ElasticAgentCard renders 1`] = ` - - -
- + - - Add Elastic Agent - - } - href="/app/integrations/browse" - image="test-file-stub" - paddingSize="l" - title="Add Elastic Agent" +
- - - - , - ], - }, + + + Add Elastic Agent + } - } - /> - -
-
-
- -
-
-
- - - - Add Elastic Agent - - - - + + , + ], + }, + } + } + isStringTag={false} + serialized={ + Object { + "map": undefined, + "name": "1hu4pg0-EuiCard", + "next": undefined, + "styles": "max-width:400px;margin-inline:auto;;label:EuiCard;", + "toString": [Function], + } + } + /> +
-

- Use Elastic Agent for a simple, unified way to collect data from your machines. -

-
-
-
-
- - -
+
- - Add Elastic Agent - + - - - - -
-
-
-
- - -
-
- + + +
+

+ Use Elastic Agent for a simple, unified way to collect data from your machines. +

+
+
+
+
+ + + + + +
+ + + + +
+ + + + + + `; diff --git a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.component.test.tsx b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.component.test.tsx index f25edb069c6293e..367fcd10b96a920 100644 --- a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.component.test.tsx +++ b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.component.test.tsx @@ -10,31 +10,15 @@ import { shallow } from 'enzyme'; import React from 'react'; import { ElasticAgentCardComponent } from './elastic_agent_card.component'; import { NoDataCard } from './no_data_card'; -import { Subject } from 'rxjs'; describe('ElasticAgentCardComponent', () => { - const navigateToUrl = jest.fn(); - const currentAppId$ = new Subject().asObservable(); - test('renders', () => { - const component = shallow( - - ); + const component = shallow(); expect(component).toMatchSnapshot(); }); test('renders with canAccessFleet false', () => { - const component = shallow( - - ); + const component = shallow(); expect(component.find(NoDataCard).props().isDisabled).toBe(true); expect(component).toMatchSnapshot(); }); @@ -42,12 +26,7 @@ describe('ElasticAgentCardComponent', () => { describe('props', () => { test('button', () => { const component = shallow( - + ); expect(component.find(NoDataCard).props().button).toBe('Button'); expect(component).toMatchSnapshot(); @@ -55,12 +34,7 @@ describe('ElasticAgentCardComponent', () => { test('href', () => { const component = shallow( - + ); expect(component.find(NoDataCard).props().href).toBe('some path'); expect(component).toMatchSnapshot(); diff --git a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.component.tsx b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.component.tsx index 0bca3929f4c2d42..7b046bbe3fe8c20 100644 --- a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.component.tsx +++ b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.component.tsx @@ -9,16 +9,12 @@ import React, { FunctionComponent } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiTextColor } from '@elastic/eui'; -import { Observable } from 'rxjs'; import { ElasticAgentCardProps } from './types'; import { NoDataCard } from './no_data_card'; import ElasticAgentCardIllustration from './assets/elastic_agent_card.svg'; -import { RedirectAppLinks } from '../../../redirect_app_links'; export type ElasticAgentCardComponentProps = ElasticAgentCardProps & { canAccessFleet: boolean; - navigateToUrl: (url: string) => Promise; - currentAppId$: Observable; }; const noPermissionTitle = i18n.translate( @@ -54,32 +50,19 @@ const elasticAgentCardDescription = i18n.translate( */ export const ElasticAgentCardComponent: FunctionComponent = ({ canAccessFleet, - title, - navigateToUrl, - currentAppId$, + title = elasticAgentCardTitle, ...cardRest }) => { - const noAccessCard = ( - {noPermissionTitle}} - description={{noPermissionDescription}} - isDisabled - {...cardRest} - /> - ); - const card = ( - - ); + const props = canAccessFleet + ? { + title, + description: elasticAgentCardDescription, + } + : { + title: {noPermissionTitle}, + description: {noPermissionDescription}, + isDisabled: true, + }; - return ( - - {canAccessFleet ? card : noAccessCard} - - ); + return ; }; diff --git a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.stories.tsx b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.stories.tsx index 77c41cddde6daca..84cbfb1c73a9495 100644 --- a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.stories.tsx +++ b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.stories.tsx @@ -7,29 +7,23 @@ */ import React from 'react'; -import { applicationServiceFactory } from '@kbn/shared-ux-storybook'; import { - ElasticAgentCardComponent, - ElasticAgentCardComponentProps, + ElasticAgentCardComponent as Component, + ElasticAgentCardComponentProps as ComponentProps, } from './elastic_agent_card.component'; +import { ElasticAgentCard } from './elastic_agent_card'; + export default { title: 'Page Template/No Data/Elastic Agent Data Card', description: 'A solution-specific wrapper around NoDataCard, to be used on NoData page', }; -type Params = Pick; +type Params = Pick; export const PureComponent = (params: Params) => { - const { currentAppId$, navigateToUrl } = applicationServiceFactory(); - return ( - - ); + return ; }; PureComponent.argTypes = { @@ -38,3 +32,7 @@ PureComponent.argTypes = { defaultValue: true, }, }; + +export const ConnectedComponent = () => { + return ; +}; diff --git a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.tsx b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.tsx index 42d42dd805650ff..3702dd4a456a710 100644 --- a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.tsx +++ b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_card/elastic_agent_card.tsx @@ -6,8 +6,10 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { useApplication, useHttp, usePermissions } from '@kbn/shared-ux-services'; +import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app'; +import useObservable from 'react-use/lib/useObservable'; import { ElasticAgentCardProps } from './types'; import { ElasticAgentCardComponent } from './elastic_agent_card.component'; @@ -16,27 +18,28 @@ export const ElasticAgentCard = (props: ElasticAgentCardProps) => { const { canAccessFleet } = usePermissions(); const { addBasePath } = useHttp(); const { navigateToUrl, currentAppId$ } = useApplication(); + const currentAppId = useObservable(currentAppId$); - const createHref = () => { - const { href, category } = props; - if (href) { - return href; + const { href: srcHref, category } = props; + + const href = useMemo(() => { + if (srcHref) { + return srcHref; } + // TODO: get this URL from a locator const prefix = '/app/integrations/browse'; + if (category) { return addBasePath(`${prefix}/${category}`); } + return addBasePath(prefix); - }; + }, [addBasePath, srcHref, category]); return ( - + + + ); }; diff --git a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_page.tsx b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_page.tsx index f16f87039a62641..837eb5282507fac 100644 --- a/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_page.tsx +++ b/packages/kbn-shared-ux-components/src/page_template/no_data_page/no_data_page.tsx @@ -7,14 +7,15 @@ */ import React, { useMemo, FunctionComponent } from 'react'; -import { i18n } from '@kbn/i18n'; +import classNames from 'classnames'; import { EuiLink, EuiSpacer, EuiText, EuiTextColor } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import classNames from 'classnames'; +import { KibanaSolutionAvatar } from '@kbn/shared-ux-avatar-solution'; + import { ElasticAgentCard } from './no_data_card'; import { NoDataPageProps } from './types'; -import { KibanaSolutionAvatar } from '../../solution_avatar'; export const NoDataPage: FunctionComponent = ({ solution, diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/__snapshots__/solution_nav.test.tsx.snap b/packages/kbn-shared-ux-components/src/page_template/solution_nav/__snapshots__/solution_nav.test.tsx.snap index fce0e996d99cd85..dfdf85d0d3563a2 100644 --- a/packages/kbn-shared-ux-components/src/page_template/solution_nav/__snapshots__/solution_nav.test.tsx.snap +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/__snapshots__/solution_nav.test.tsx.snap @@ -2,266 +2,322 @@ exports[`KibanaPageTemplateSolutionNav accepts EuiSideNavProps 1`] = ` - - - Solution - - + initialIsOpen={false} + isCollapsible={true} + paddingSize="m" + title={ + +

+ + + +

+
} - isOpenOnMobile={false} - items={ - Array [ - Object { - "id": "1", - "items": Array [ - Object { - "id": "1.1", - "items": undefined, - "name": "Ingest Node Pipelines", - "tabIndex": undefined, - }, - Object { - "id": "1.2", - "items": undefined, - "name": "Logstash Pipelines", - "tabIndex": undefined, - }, - Object { - "id": "1.3", - "items": undefined, - "name": "Beats Central Management", - "tabIndex": undefined, - }, - ], - "name": "Ingest", - "tabIndex": undefined, - }, - Object { - "id": "2", - "items": Array [ - Object { - "id": "2.1", - "items": undefined, - "name": "Index Management", - "tabIndex": undefined, - }, - Object { - "id": "2.2", - "items": undefined, - "name": "Index Lifecycle Policies", - "tabIndex": undefined, - }, - Object { - "id": "2.3", - "items": undefined, - "name": "Snapshot and Restore", - "tabIndex": undefined, - }, - ], - "name": "Data", - "tabIndex": undefined, - }, - ] - } - mobileTitle={ - - - + titleElement="span" + > + + +
+`; + +exports[`KibanaPageTemplateSolutionNav heading accepts more headingProps 1`] = ` + + +

+ + + +

+ } - toggleOpenOnMobile={[Function]} + titleElement="span" />
`; exports[`KibanaPageTemplateSolutionNav renders 1`] = ` - - - Solution - - + initialIsOpen={false} + isCollapsible={true} + paddingSize="m" + title={ + +

+ + + +

+
} - isOpenOnMobile={false} - items={ - Array [ - Object { - "id": "1", - "items": Array [ - Object { - "id": "1.1", - "items": undefined, - "name": "Ingest Node Pipelines", - "tabIndex": undefined, - }, - Object { - "id": "1.2", - "items": undefined, - "name": "Logstash Pipelines", - "tabIndex": undefined, - }, - Object { - "id": "1.3", - "items": undefined, - "name": "Beats Central Management", - "tabIndex": undefined, - }, - ], - "name": "Ingest", - "tabIndex": undefined, - }, - Object { - "id": "2", - "items": Array [ - Object { - "id": "2.1", - "items": undefined, - "name": "Index Management", - "tabIndex": undefined, - }, - Object { - "id": "2.2", - "items": undefined, - "name": "Index Lifecycle Policies", - "tabIndex": undefined, - }, - Object { - "id": "2.3", - "items": undefined, - "name": "Snapshot and Restore", - "tabIndex": undefined, - }, - ], - "name": "Data", - "tabIndex": undefined, - }, - ] - } - mobileTitle={ - - - - } - toggleOpenOnMobile={[Function]} - /> + titleElement="span" + > + +
`; exports[`KibanaPageTemplateSolutionNav renders with icon 1`] = ` - - - - Solution - - + initialIsOpen={false} + isCollapsible={true} + paddingSize="m" + title={ + +

+ + + + +

+
} - isOpenOnMobile={false} - items={ - Array [ - Object { - "id": "1", - "items": Array [ - Object { - "id": "1.1", - "items": undefined, - "name": "Ingest Node Pipelines", - "tabIndex": undefined, - }, - Object { - "id": "1.2", - "items": undefined, - "name": "Logstash Pipelines", - "tabIndex": undefined, - }, - Object { - "id": "1.3", - "items": undefined, - "name": "Beats Central Management", - "tabIndex": undefined, - }, - ], - "name": "Ingest", - "tabIndex": undefined, - }, - Object { - "id": "2", - "items": Array [ - Object { - "id": "2.1", - "items": undefined, - "name": "Index Management", - "tabIndex": undefined, - }, - Object { - "id": "2.2", - "items": undefined, - "name": "Index Lifecycle Policies", - "tabIndex": undefined, - }, - Object { - "id": "2.3", - "items": undefined, - "name": "Snapshot and Restore", - "tabIndex": undefined, - }, - ], - "name": "Data", - "tabIndex": undefined, - }, - ] - } - mobileTitle={ - - - - - } - toggleOpenOnMobile={[Function]} - /> + titleElement="span" + > + +
`; diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.scss b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.scss index d0070cef729b73c..c21f5e1bbab99b8 100644 --- a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.scss +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.scss @@ -10,12 +10,15 @@ $euiSideNavEmphasizedBackgroundColor: transparentize($euiColorLightShade, .7); @include euiSideNavEmbellish; @include euiYScroll; + display: flex; + flex-direction: column; + @include euiBreakpoint('m' ,'l', 'xl') { width: 248px; padding: $euiSizeL; } - .kbnPageTemplateSolutionNavAvatar { + .kbnPageTemplateSolutionNav__avatar { margin-right: $euiSize; } } diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.stories.tsx b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.stories.tsx index 5ff1e2c07d9d87f..28550a7789a9fa2 100644 --- a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.stories.tsx +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.stories.tsx @@ -69,4 +69,12 @@ PureComponent.argTypes = { options: ['logoKibana', 'logoObservability', 'logoSecurity'], defaultValue: 'logoKibana', }, + children: { + control: 'text', + defaultValue: '', + }, +}; + +PureComponent.parameters = { + layout: 'fullscreen', }; diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.test.tsx b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.test.tsx index 2792ae518e5a2a8..9e2eac4cf20d635 100644 --- a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.test.tsx +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.test.tsx @@ -10,14 +10,15 @@ import React from 'react'; import { shallow } from 'enzyme'; import { KibanaPageTemplateSolutionNav, KibanaPageTemplateSolutionNavProps } from './solution_nav'; -jest.mock('@elastic/eui', () => ({ - useIsWithinBreakpoints: (args: string[]) => { - return args[0] === 'xs'; - }, - EuiSideNav: function Component() { - // no-op - }, -})); +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + return { + ...original, + useIsWithinBreakpoints: (args: string[]) => { + return args[0] === 'xs'; + }, + }; +}); const items: KibanaPageTemplateSolutionNavProps['items'] = [ { @@ -59,6 +60,19 @@ const items: KibanaPageTemplateSolutionNavProps['items'] = [ ]; describe('KibanaPageTemplateSolutionNav', () => { + describe('heading', () => { + test('accepts more headingProps', () => { + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + }); + }); + test('renders', () => { const component = shallow(); expect(component).toMatchSnapshot(); @@ -71,6 +85,15 @@ describe('KibanaPageTemplateSolutionNav', () => { expect(component).toMatchSnapshot(); }); + test('renders with children', () => { + const component = shallow( + + + + ); + expect(component.find('#dummy_component').length > 0).toBeTruthy(); + }); + test('accepts EuiSideNavProps', () => { const component = shallow( diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.tsx b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.tsx index 8bc91789c705410..191e56db530a9d4 100644 --- a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.tsx +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.tsx @@ -7,23 +7,31 @@ */ import './solution_nav.scss'; -import React, { FunctionComponent, useState } from 'react'; -import { FormattedMessage } from '@kbn/i18n-react'; - +import React, { FunctionComponent, useState, useMemo } from 'react'; +import classNames from 'classnames'; import { EuiAvatarProps, + EuiCollapsibleNavGroup, EuiFlyout, + EuiFlyoutProps, EuiSideNav, EuiSideNavItemType, EuiSideNavProps, + EuiSpacer, + EuiTitle, + htmlIdGenerator, useIsWithinBreakpoints, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { KibanaSolutionAvatar } from '@kbn/shared-ux-avatar-solution'; -import classNames from 'classnames'; -import { KibanaSolutionAvatar } from '../../solution_avatar'; import { KibanaPageTemplateSolutionNavCollapseButton } from './solution_nav_collapse_button'; -export type KibanaPageTemplateSolutionNavProps = EuiSideNavProps<{}> & { +export type KibanaPageTemplateSolutionNavProps = Omit< + EuiSideNavProps<{}>, + 'children' | 'items' | 'heading' +> & { /** * Name of the solution, i.e. "Observability" */ @@ -32,6 +40,19 @@ export type KibanaPageTemplateSolutionNavProps = EuiSideNavProps<{}> & { * Solution logo, i.e. "logoObservability" */ icon?: EuiAvatarProps['iconType']; + /** + * An array of #EuiSideNavItem objects. Lists navigation menu items. + */ + items?: EuiSideNavProps<{}>['items']; + /** + * Renders the children instead of default EuiSideNav + */ + children?: React.ReactNode; + /** + * The position of the close button when the navigation flyout is open. + * Note that side navigation turns into a flyout only when the screen has medium size. + */ + closeFlyoutButtonPosition?: EuiFlyoutProps['closeButtonPosition']; /** * Control the collapsed state */ @@ -50,13 +71,26 @@ const setTabIndex = (items: Array>, isHidden: boolean) => }); }; +const generateId = htmlIdGenerator('KibanaPageTemplateSolutionNav'); + /** * A wrapper around EuiSideNav but also creates the appropriate title with optional solution logo */ export const KibanaPageTemplateSolutionNav: FunctionComponent< KibanaPageTemplateSolutionNavProps -> = ({ name, icon, items, isOpenOnDesktop = false, onCollapse, ...rest }) => { - const isSmallerBreakpoint = useIsWithinBreakpoints(['xs', 's']); +> = ({ + children, + headingProps, + icon, + isOpenOnDesktop = false, + items, + mobileBreakpoints = ['xs', 's'], + closeFlyoutButtonPosition = 'outside', + name, + onCollapse, + ...rest +}) => { + const isSmallerBreakpoint = useIsWithinBreakpoints(mobileBreakpoints); const isMediumBreakpoint = useIsWithinBreakpoints(['m']); const isLargerBreakpoint = useIsWithinBreakpoints(['l', 'xl']); @@ -67,68 +101,81 @@ export const KibanaPageTemplateSolutionNav: FunctionComponent< }; const isHidden = isLargerBreakpoint && !isOpenOnDesktop; + const isCustomSideNav = !!children; - /** - * Create the avatar - */ - const solutionAvatar = icon ? ( - - ) : null; + const sideNavClasses = classNames('kbnPageTemplateSolutionNav', { + 'kbnPageTemplateSolutionNav--hidden': isHidden, + }); /** - * Create the titles + * Create the avatar and titles */ + const headingID = headingProps?.id || generateId('heading'); + const HeadingElement = headingProps?.element || 'h2'; const titleText = ( - <> - {solutionAvatar} - {name} - - ); - const mobileTitleText = ( - + + + {icon && ( + + )} + + + + + ); /** - * Create the side nav component + * Create the side nav content */ - - const sideNav = () => { + const sideNavContent = useMemo(() => { + if (isCustomSideNav) { + return children; + } if (!items) { return null; } - const sideNavClasses = classNames('kbnPageTemplateSolutionNav', { - 'kbnPageTemplateSolutionNav--hidden': isHidden, - }); return ( - {solutionAvatar} - {mobileTitleText} - - } - toggleOpenOnMobile={toggleOpenOnMobile} - isOpenOnMobile={isSideNavOpenOnMobile} items={setTabIndex(items, isHidden)} + mobileBreakpoints={[]} // prevent EuiSideNav to apply mobile version, already implemented here {...rest} /> ); - }; + }, [children, headingID, isCustomSideNav, isHidden, items, rest]); return ( <> - {isSmallerBreakpoint && sideNav()} + {isSmallerBreakpoint && ( + + {sideNavContent} + + )} {isMediumBreakpoint && ( <> {isSideNavOpenOnMobile && ( @@ -138,10 +185,14 @@ export const KibanaPageTemplateSolutionNav: FunctionComponent< onClose={() => setIsSideNavOpenOnMobile(false)} side="left" size={FLYOUT_SIZE} - closeButtonPosition="outside" + closeButtonPosition={closeFlyoutButtonPosition} className="kbnPageTemplateSolutionNav__flyout" > - {sideNav()} +
+ {titleText} + + {sideNavContent} +
)} - {sideNav()} +
+ {titleText} + + {sideNavContent} +
; - container?: HTMLElement; -} - -export const createNavigateToUrlClickHandler = ({ - container, - navigateToUrl, -}: CreateCrossAppClickHandlerOptions): React.MouseEventHandler => { - return (e) => { - if (!container) { - return; - } - // see https://github.com/DefinitelyTyped/DefinitelyTyped/pull/12239 - const target = e.target as HTMLElement; - - const link = getClosestLink(target, container); - if (!link) { - return; - } - - const isNotEmptyHref = link.href; - const hasNoTarget = link.target === '' || link.target === '_self'; - const isLeftClickOnly = e.button === 0; - - if ( - isNotEmptyHref && - hasNoTarget && - isLeftClickOnly && - !e.defaultPrevented && - !hasActiveModifierKey(e) - ) { - e.preventDefault(); - navigateToUrl(link.href); - } - }; -}; diff --git a/packages/kbn-shared-ux-components/src/redirect_app_links/index.ts b/packages/kbn-shared-ux-components/src/redirect_app_links/index.ts deleted file mode 100644 index db7462d7cb1bf03..000000000000000 --- a/packages/kbn-shared-ux-components/src/redirect_app_links/index.ts +++ /dev/null @@ -1,18 +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. - */ -/* eslint-disable import/no-default-export */ - -import { RedirectAppLinks } from './redirect_app_links'; -export type { RedirectAppLinksProps } from './redirect_app_links'; -export { RedirectAppLinks } from './redirect_app_links'; - -/** - * Exporting the RedirectAppLinks component as a default export so it can be - * loaded by React.lazy. - */ -export default RedirectAppLinks; diff --git a/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.mdx b/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.mdx deleted file mode 100644 index 0023182940ae973..000000000000000 --- a/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.mdx +++ /dev/null @@ -1,12 +0,0 @@ ---- -id: sharedUX/Components/AppLink -slug: /shared-ux/components/redirect-app-link -title: Redirect App Link -summary: The component for redirect links. -tags: ['shared-ux', 'component'] -date: 2022-02-01 ---- - -> This documentation is in progress. - -**This component has been refactored.** Instead of requiring the entire `application`, it instead takes just `navigateToUrl` and `currentAppId$`. This makes the component more lightweight. diff --git a/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.stories.tsx b/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.stories.tsx deleted file mode 100644 index 0ca0e2a8d997800..000000000000000 --- a/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.stories.tsx +++ /dev/null @@ -1,43 +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 { EuiButton } from '@elastic/eui'; -import React from 'react'; -import { BehaviorSubject } from 'rxjs'; - -import { action } from '@storybook/addon-actions'; -import { RedirectAppLinks } from './redirect_app_links'; -import mdx from './redirect_app_links.mdx'; - -export default { - title: 'Redirect App Links', - description: 'app links component that takes in an application id and navigation url.', - parameters: { - docs: { - page: mdx, - }, - }, -}; - -export const Component = () => { - return ( - Promise.resolve()} - currentAppId$={new BehaviorSubject('test')} - > - - Test link - - - ); -}; diff --git a/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.test.tsx b/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.test.tsx deleted file mode 100644 index d36bace70b7c8ca..000000000000000 --- a/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.test.tsx +++ /dev/null @@ -1,249 +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 React, { MouseEvent } from 'react'; -import { mount } from 'enzyme'; -import { BehaviorSubject } from 'rxjs'; - -import { RedirectAppLinks } from './redirect_app_links'; - -export type UnmountCallback = () => void; -export type MountPoint = (element: T) => UnmountCallback; - -const createServiceMock = () => { - const currentAppId$ = new BehaviorSubject('currentApp'); - - return { - currentAppId$: currentAppId$.asObservable(), - navigateToApp: jest.fn(), - navigateToUrl: jest.fn(), - }; -}; - -/* eslint-disable jsx-a11y/click-events-have-key-events */ - -describe('RedirectAppLinks', () => { - let application = createServiceMock(); - - beforeEach(() => { - application = createServiceMock(); - }); - - it('intercept click events on children link elements', () => { - let event: MouseEvent; - const component = mount( -
{ - event = e; - }} - > - -
- content -
-
-
- ); - - component.find('a').simulate('click', { button: 0, defaultPrevented: false }); - expect(application.navigateToUrl).toHaveBeenCalledTimes(1); - expect(event!.defaultPrevented).toBe(true); - }); - - it('intercept click events on children inside link elements', async () => { - let event: MouseEvent; - - const component = mount( -
{ - event = e; - }} - > - - - -
- ); - - component.find('span').simulate('click', { button: 0, defaultPrevented: false }); - - expect(application.navigateToUrl).toHaveBeenCalledTimes(1); - expect(event!.defaultPrevented).toBe(true); - }); - - it('does not intercept click events when the target is not inside a link', () => { - let event: MouseEvent; - - const component = mount( -
{ - event = e; - }} - > - - - content - - -
- ); - - component.find('span').simulate('click', { button: 0, defaultPrevented: false }); - - expect(application.navigateToApp).not.toHaveBeenCalled(); - expect(event!.defaultPrevented).toBe(false); - }); - - it('does not intercept click events when the link is a parent of the container', () => { - let event: MouseEvent; - - const component = mount( -
{ - event = e; - }} - > - - - content - - -
- ); - - component.find('span').simulate('click', { button: 0, defaultPrevented: false }); - - expect(application.navigateToApp).not.toHaveBeenCalled(); - expect(event!.defaultPrevented).toBe(false); - }); - - it('does not intercept click events when the link has an external target', () => { - let event: MouseEvent; - - const component = mount( -
{ - event = e; - }} - > - - - content - - -
- ); - - component.find('a').simulate('click', { button: 0, defaultPrevented: false }); - - expect(application.navigateToApp).not.toHaveBeenCalled(); - expect(event!.defaultPrevented).toBe(false); - }); - - it('does not intercept click events when the event is already defaultPrevented', () => { - let event: MouseEvent; - - const component = mount( -
{ - event = e; - }} - > - - - e.preventDefault()}>content - - -
- ); - - component.find('span').simulate('click', { button: 0, defaultPrevented: false }); - - expect(application.navigateToApp).not.toHaveBeenCalled(); - expect(event!.defaultPrevented).toBe(true); - }); - - it('does not intercept click events when the event propagation is stopped', () => { - let event: MouseEvent; - - const component = mount( -
{ - event = e; - }} - > - - e.stopPropagation()}> - content - - -
- ); - - component.find('a').simulate('click', { button: 0, defaultPrevented: false }); - - expect(application.navigateToApp).not.toHaveBeenCalled(); - expect(event!).toBe(undefined); - }); - - it('does not intercept click events when the event is not triggered from the left button', () => { - let event: MouseEvent; - - const component = mount( -
{ - event = e; - }} - > - -
- content -
-
-
- ); - - component.find('a').simulate('click', { button: 1, defaultPrevented: false }); - - expect(application.navigateToApp).not.toHaveBeenCalled(); - expect(event!.defaultPrevented).toBe(false); - }); - - it('does not intercept click events when the event has a modifier key enabled', () => { - let event: MouseEvent; - - const component = mount( -
{ - event = e; - }} - > - -
- content -
-
-
- ); - - component.find('a').simulate('click', { button: 0, ctrlKey: true, defaultPrevented: false }); - - expect(application.navigateToApp).not.toHaveBeenCalled(); - expect(event!.defaultPrevented).toBe(false); - }); -}); diff --git a/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.tsx b/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.tsx deleted file mode 100644 index e1d0bd4bed653e2..000000000000000 --- a/packages/kbn-shared-ux-components/src/redirect_app_links/redirect_app_links.tsx +++ /dev/null @@ -1,68 +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 React, { useRef, useMemo } from 'react'; -import type { HTMLAttributes, DetailedHTMLProps, FC } from 'react'; -import useObservable from 'react-use/lib/useObservable'; -import { Observable } from 'rxjs'; - -import { createNavigateToUrlClickHandler } from './click_handler'; - -type DivProps = DetailedHTMLProps, HTMLDivElement>; -/** - * TODO: this interface recreates props from the `ApplicationStart` interface. - * see: https://github.com/elastic/kibana/issues/127695 - */ -export interface RedirectAppLinksProps extends DivProps { - currentAppId$: Observable; - navigateToUrl(url: string): Promise; -} - -/** - * Utility component that will intercept click events on children anchor (``) elements to call - * `application.navigateToUrl` with the link's href. This will trigger SPA friendly navigation - * when the link points to a valid Kibana app. - * - * @example - * ```tsx - * url} currentAppId$={observableAppId}> - * Go to another-app - * - * ``` - * - * @remarks - * It is recommended to use the component at the highest possible level of the component tree that would - * require to handle the links. A good practice is to consider it as a context provider and to use it - * at the root level of an application or of the page that require the feature. - */ -export const RedirectAppLinks: FC = ({ - navigateToUrl, - currentAppId$, - children, - ...otherProps -}) => { - const currentAppId = useObservable(currentAppId$, undefined); - const containerRef = useRef(null); - const clickHandler = useMemo( - () => - containerRef.current && currentAppId - ? createNavigateToUrlClickHandler({ - container: containerRef.current, - navigateToUrl, - }) - : undefined, - [currentAppId, navigateToUrl] - ); - - return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events -
- {children} -
- ); -}; diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.stories.tsx b/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.stories.tsx deleted file mode 100644 index bc26806016df0d7..000000000000000 --- a/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.stories.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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 React from 'react'; -import { KibanaSolutionAvatar, KibanaSolutionAvatarProps } from './solution_avatar'; - -export default { - title: 'Solution Avatar', - description: 'A wrapper around EuiAvatar, specifically to stylize Elastic Solutions', -}; - -type Params = Pick; - -export const PureComponent = (params: Params) => { - return ; -}; - -PureComponent.argTypes = { - name: { - control: 'text', - defaultValue: 'Kibana', - }, - size: { - control: 'radio', - options: ['s', 'm', 'l', 'xl', 'xxl'], - defaultValue: 'xxl', - }, -}; diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.tsx b/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.tsx deleted file mode 100644 index deb71affc9c1a9f..000000000000000 --- a/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.tsx +++ /dev/null @@ -1,44 +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 './solution_avatar.scss'; - -import React from 'react'; - -import { DistributiveOmit, EuiAvatar, EuiAvatarProps } from '@elastic/eui'; -import classNames from 'classnames'; - -export type KibanaSolutionAvatarProps = DistributiveOmit & { - /** - * Any EuiAvatar size available, or `xxl` for custom large, brand-focused version - */ - size?: EuiAvatarProps['size'] | 'xxl'; -}; - -/** - * Applies extra styling to a typical EuiAvatar. - * The `name` value will be appended to 'logo' to configure the `iconType` unless `iconType` is provided. - */ -export const KibanaSolutionAvatar = ({ className, size, ...rest }: KibanaSolutionAvatarProps) => { - return ( - // @ts-ignore Complains about ExclusiveUnion between `iconSize` and `iconType`, but works fine - - ); -}; diff --git a/packages/kbn-shared-ux-components/src/toolbar/buttons/icon_button_group/__snapshots__/icon_button_group.test.tsx.snap b/packages/kbn-shared-ux-components/src/toolbar/buttons/icon_button_group/__snapshots__/icon_button_group.test.tsx.snap index c3b7dc63bce947b..8091bd222d1a326 100644 --- a/packages/kbn-shared-ux-components/src/toolbar/buttons/icon_button_group/__snapshots__/icon_button_group.test.tsx.snap +++ b/packages/kbn-shared-ux-components/src/toolbar/buttons/icon_button_group/__snapshots__/icon_button_group.test.tsx.snap @@ -4,7 +4,9 @@ exports[` is rendered 1`] = ` is rendered 1`] = ` void; + /** If set to false, will skip empty prompt in data view editor. */ + showEmptyPrompt?: boolean; } /** diff --git a/packages/kbn-shared-ux-services/src/services/mock/application.mock.ts b/packages/kbn-shared-ux-services/src/services/mock/application.mock.ts index abb68a1edb642b2..947113c4a31686c 100644 --- a/packages/kbn-shared-ux-services/src/services/mock/application.mock.ts +++ b/packages/kbn-shared-ux-services/src/services/mock/application.mock.ts @@ -17,5 +17,7 @@ export type MockApplicationServiceFactory = ServiceFactory ({ navigateToUrl: () => Promise.resolve(), - currentAppId$: new Observable(), + currentAppId$: new Observable((subscriber) => { + subscriber.next('abc123'); + }), }); diff --git a/packages/kbn-shared-ux-storybook/src/services/application.ts b/packages/kbn-shared-ux-storybook/src/services/application.ts index 2a544445fc474c4..1b16526bc8be85d 100644 --- a/packages/kbn-shared-ux-storybook/src/services/application.ts +++ b/packages/kbn-shared-ux-storybook/src/services/application.ts @@ -16,8 +16,8 @@ export type ApplicationServiceFactory = ServiceFactory ({ - navigateToUrl: () => { - action('NavigateToUrl'); + navigateToUrl: (url) => { + action('navigateToUrl')(url); return Promise.resolve(); }, currentAppId$: new BehaviorSubject('123'), diff --git a/packages/kbn-test-jest-helpers/BUILD.bazel b/packages/kbn-test-jest-helpers/BUILD.bazel index dc8b83495494cbe..85192829003e4f9 100644 --- a/packages/kbn-test-jest-helpers/BUILD.bazel +++ b/packages/kbn-test-jest-helpers/BUILD.bazel @@ -60,7 +60,6 @@ RUNTIME_DEPS = [ "@npm//joi", "@npm//mustache", "@npm//normalize-path", - "@npm//parse-link-header", "@npm//prettier", "@npm//react", "@npm//react-dom", @@ -106,7 +105,6 @@ TYPES_DEPS = [ "@npm//@types/mustache", "@npm//@types/normalize-path", "@npm//@types/node", - "@npm//@types/parse-link-header", "@npm//@types/prettier", "@npm//@types/react", "@npm//@types/react-dom", diff --git a/packages/kbn-test/BUILD.bazel b/packages/kbn-test/BUILD.bazel index f7599e6d8164988..15487aa781b8dab 100644 --- a/packages/kbn-test/BUILD.bazel +++ b/packages/kbn-test/BUILD.bazel @@ -67,7 +67,6 @@ RUNTIME_DEPS = [ "@npm//js-yaml", "@npm//mustache", "@npm//normalize-path", - "@npm//parse-link-header", "@npm//prettier", "@npm//react-dom", "@npm//react-redux", @@ -115,7 +114,6 @@ TYPES_DEPS = [ "@npm//@types/mustache", "@npm//@types/normalize-path", "@npm//@types/node", - "@npm//@types/parse-link-header", "@npm//@types/prettier", "@npm//@types/react-dom", "@npm//@types/react-redux", diff --git a/packages/kbn-test/src/es/test_es_cluster.ts b/packages/kbn-test/src/es/test_es_cluster.ts index 42dc19445c29319..c065cb01a4c3646 100644 --- a/packages/kbn-test/src/es/test_es_cluster.ts +++ b/packages/kbn-test/src/es/test_es_cluster.ts @@ -146,6 +146,11 @@ export interface CreateTestEsClusterOptions { * defaults to the transport port from `packages/kbn-test/src/es/es_test_config.ts` */ transportPort?: number | string; + /** + * Report to the creator of the es-test-cluster that the es node has exitted before stop() was called, allowing + * this caller to react appropriately. If this is not passed then an uncatchable exception will be thrown + */ + onEarlyExit?: (msg: string) => void; } export function createTestEsCluster< @@ -165,6 +170,7 @@ export function createTestEsCluster< clusterName: customClusterName = 'es-test-cluster', ssl, transportPort, + onEarlyExit, } = options; const clusterName = `${CI_PARALLEL_PROCESS_PREFIX}${customClusterName}`; @@ -258,6 +264,7 @@ export function createTestEsCluster< // set it up after the last node is started. skipNativeRealmSetup: this.nodes.length > 1 && i < this.nodes.length - 1, skipReadyCheck: this.nodes.length > 1 && i < this.nodes.length - 1, + onEarlyExit, }); }); } diff --git a/packages/kbn-test/src/functional_test_runner/cli.ts b/packages/kbn-test/src/functional_test_runner/cli.ts index 4159533e628bcad..f71e4ac7d6ccd1d 100644 --- a/packages/kbn-test/src/functional_test_runner/cli.ts +++ b/packages/kbn-test/src/functional_test_runner/cli.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { resolve } from 'path'; +import Path from 'path'; import { inspect } from 'util'; import { run, createFlagError, Flags } from '@kbn/dev-utils'; @@ -16,7 +16,7 @@ import exitHook from 'exit-hook'; import { FunctionalTestRunner } from './functional_test_runner'; -const makeAbsolutePath = (v: string) => resolve(process.cwd(), v); +const makeAbsolutePath = (v: string) => Path.resolve(process.cwd(), v); const toArray = (v: string | string[]) => ([] as string[]).concat(v || []); const parseInstallDir = (flags: Flags) => { const flag = flags['kibana-install-dir']; @@ -42,9 +42,15 @@ export function runFtrCli() { throw createFlagError('expected --es-version to be a string'); } + const configRel = flags.config; + if (typeof configRel !== 'string' || !configRel) { + throw createFlagError('--config is required'); + } + const configPath = makeAbsolutePath(configRel); + const functionalTestRunner = new FunctionalTestRunner( log, - makeAbsolutePath(flags.config as string), + configPath, { mochaOpts: { bail: flags.bail, @@ -69,6 +75,8 @@ export function runFtrCli() { esVersion ); + await functionalTestRunner.readConfigFile(); + if (flags.throttle) { process.env.TEST_THROTTLE_NETWORK = '1'; } @@ -149,9 +157,6 @@ export function runFtrCli() { 'headless', 'dry-run', ], - default: { - config: 'test/functional/config.js', - }, help: ` --config=path path to a config file --bail stop tests after the first failure diff --git a/packages/kbn-test/src/functional_test_runner/fake_mocha_types.ts b/packages/kbn-test/src/functional_test_runner/fake_mocha_types.ts index 96ebcd79c4e4363..506b6f139f7364f 100644 --- a/packages/kbn-test/src/functional_test_runner/fake_mocha_types.ts +++ b/packages/kbn-test/src/functional_test_runner/fake_mocha_types.ts @@ -37,6 +37,7 @@ export interface Test { export interface Runner extends EventEmitter { abort(): void; failures: any[]; + uncaught: (error: Error) => void; } export interface Mocha { diff --git a/packages/kbn-test/src/functional_test_runner/functional_test_runner.ts b/packages/kbn-test/src/functional_test_runner/functional_test_runner.ts index 0ceba511f9b9bfa..9de6500a4532345 100644 --- a/packages/kbn-test/src/functional_test_runner/functional_test_runner.ts +++ b/packages/kbn-test/src/functional_test_runner/functional_test_runner.ts @@ -43,7 +43,7 @@ export class FunctionalTestRunner { : new EsVersion(esVersion); } - async run() { + async run(abortSignal?: AbortSignal) { const testStats = await this.getTestStats(); return await this.runHarness(async (config, lifecycle, coreProviders) => { @@ -106,10 +106,19 @@ export class FunctionalTestRunner { return this.simulateMochaDryRun(mocha); } + if (abortSignal?.aborted) { + this.log.warning('run aborted'); + return; + } + await lifecycle.beforeTests.trigger(mocha.suite); - this.log.info('Starting tests'); + if (abortSignal?.aborted) { + this.log.warning('run aborted'); + return; + } - return await runTests(lifecycle, mocha); + this.log.info('Starting tests'); + return await runTests(lifecycle, mocha, abortSignal); }); } @@ -210,12 +219,7 @@ export class FunctionalTestRunner { const lifecycle = new Lifecycle(this.log); try { - const config = await readConfigFile( - this.log, - this.esVersion, - this.configFile, - this.configOverrides - ); + const config = await this.readConfigFile(); this.log.debug('Config loaded'); if ( @@ -259,6 +263,10 @@ export class FunctionalTestRunner { } } + public async readConfigFile() { + return await readConfigFile(this.log, this.esVersion, this.configFile, this.configOverrides); + } + simulateMochaDryRun(mocha: any) { interface TestEntry { file: string; diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.ts b/packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.ts index 24702d699064cdb..49a6ef16d6685e7 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/config/read_config_file.ts @@ -10,6 +10,7 @@ import Path from 'path'; import { ToolingLog } from '@kbn/tooling-log'; import { defaultsDeep } from 'lodash'; import { createFlagError } from '@kbn/dev-utils'; +import { REPO_ROOT } from '@kbn/utils'; import { Config } from './config'; import { EsVersion } from '../es_version'; @@ -26,21 +27,33 @@ async function getSettingsFromFile( primary: boolean; } ) { + let resolvedPath; + try { + resolvedPath = require.resolve(options.path); + } catch (error) { + if (error.code === 'MODULE_NOT_FOUND') { + throw createFlagError(`Unable to find config file [${options.path}]`); + } + + throw error; + } + if ( options.primary && - !FTR_CONFIGS_MANIFEST_PATHS.includes(options.path) && - !options.path.includes(`${Path.sep}__fixtures__${Path.sep}`) + !FTR_CONFIGS_MANIFEST_PATHS.includes(resolvedPath) && + !resolvedPath.includes(`${Path.sep}__fixtures__${Path.sep}`) ) { + const rel = Path.relative(REPO_ROOT, resolvedPath); throw createFlagError( - `Refusing to load FTR Config which is not listed in [${FTR_CONFIGS_MANIFEST_REL}]. All FTR Config files must be listed there, use the "enabled" key if the FTR Config should be run on automatically on PR CI, or the "disabled" key if it is run manually or by a special job.` + `Refusing to load FTR Config at [${rel}] which is not listed in [${FTR_CONFIGS_MANIFEST_REL}]. All FTR Config files must be listed there, use the "enabled" key if the FTR Config should be run on automatically on PR CI, or the "disabled" key if it is run manually or by a special job.` ); } - const configModule = require(options.path); // eslint-disable-line @typescript-eslint/no-var-requires + const configModule = require(resolvedPath); // eslint-disable-line @typescript-eslint/no-var-requires const configProvider = configModule.__esModule ? configModule.default : configModule; if (!cache.has(configProvider)) { - log.debug('Loading config file from %j', options.path); + log.debug('Loading config file from %j', resolvedPath); cache.set( configProvider, configProvider({ diff --git a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts index d2182064d352e55..bf652bf8c844447 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/config/schema.ts @@ -14,6 +14,7 @@ import type { CustomHelpers } from 'joi'; // valid pattern for ID // enforced camel-case identifiers for consistency const ID_PATTERN = /^[a-zA-Z0-9_]+$/; +const SCALABILITY_DURATION_PATTERN = /^[1-9]\d{0,}[m|s]$/; // it will search both --inspect and --inspect-brk const INSPECTING = !!process.execArgv.find((arg) => arg.includes('--inspect')); @@ -264,6 +265,67 @@ export const schema = Joi.object() }) .default(), + /** + * Optional settings to list test data archives, that will be loaded during the 'beforeTests' + * lifecycle phase and unloaded during the 'cleanup' lifecycle phase. + */ + testData: Joi.object() + .keys({ + kbnArchives: Joi.array().items(Joi.string()).default([]), + esArchives: Joi.array().items(Joi.string()).default([]), + }) + .default(), + + /** + * Optional settings to enable scalability testing for single user performance journey. + * If defined, 'scalabilitySetup' must include 'warmup' and 'test' stages, + * 'maxDuration', e.g. '10m' to limit execution time to 10 minutes. + * Each stage must include 'action', 'duration' and 'maxUsersCount'. + * In addition, 'rampConcurrentUsers' requires 'minUsersCount' to ramp users from + * min to max within provided time duration. + */ + scalabilitySetup: Joi.object() + .keys({ + warmup: Joi.object() + .keys({ + stages: Joi.array().items( + Joi.object().keys({ + action: Joi.string() + .valid('constantConcurrentUsers', 'rampConcurrentUsers') + .required(), + duration: Joi.string().pattern(SCALABILITY_DURATION_PATTERN).required(), + minUsersCount: Joi.number().when('action', { + is: 'rampConcurrentUsers', + then: Joi.number().required().less(Joi.ref('maxUsersCount')), + otherwise: Joi.forbidden(), + }), + maxUsersCount: Joi.number().required().greater(0), + }) + ), + }) + .required(), + test: Joi.object() + .keys({ + stages: Joi.array().items( + Joi.object().keys({ + action: Joi.string() + .valid('constantConcurrentUsers', 'rampConcurrentUsers') + .required(), + duration: Joi.string().pattern(SCALABILITY_DURATION_PATTERN).required(), + minUsersCount: Joi.number().when('action', { + is: 'rampConcurrentUsers', + then: Joi.number().required().less(Joi.ref('maxUsersCount')), + otherwise: Joi.forbidden(), + }), + maxUsersCount: Joi.number().required().greater(0), + }) + ), + }) + .required(), + maxDuration: Joi.string().pattern(SCALABILITY_DURATION_PATTERN).required(), + }) + .optional(), + // settings for the kibanaServer.uiSettings module uiSettings: Joi.object() .keys({ diff --git a/packages/kbn-test/src/functional_test_runner/lib/mocha/run_tests.ts b/packages/kbn-test/src/functional_test_runner/lib/mocha/run_tests.ts index 89f0ea088cac876..12840b77dd8d920 100644 --- a/packages/kbn-test/src/functional_test_runner/lib/mocha/run_tests.ts +++ b/packages/kbn-test/src/functional_test_runner/lib/mocha/run_tests.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import * as Rx from 'rxjs'; import { Lifecycle } from '../lifecycle'; import { Mocha } from '../../fake_mocha_types'; @@ -18,14 +19,23 @@ import { Mocha } from '../../fake_mocha_types'; * @param {Mocha} mocha * @return {Promise} resolves to the number of test failures */ -export async function runTests(lifecycle: Lifecycle, mocha: Mocha) { +export async function runTests(lifecycle: Lifecycle, mocha: Mocha, abortSignal?: AbortSignal) { let runComplete = false; const runner = mocha.run(() => { runComplete = true; }); - lifecycle.cleanup.add(() => { - if (!runComplete) runner.abort(); + Rx.race( + lifecycle.cleanup.before$, + abortSignal ? Rx.fromEvent(abortSignal, 'abort').pipe(Rx.take(1)) : Rx.NEVER + ).subscribe({ + next() { + if (!runComplete) { + runComplete = true; + runner.uncaught(new Error('Forcing mocha to abort')); + runner.abort(); + } + }, }); return new Promise((resolve) => { diff --git a/packages/kbn-test/src/functional_tests/lib/index.ts b/packages/kbn-test/src/functional_tests/lib/index.ts index bf2cc431595269c..2726192328bda00 100644 --- a/packages/kbn-test/src/functional_tests/lib/index.ts +++ b/packages/kbn-test/src/functional_tests/lib/index.ts @@ -10,5 +10,5 @@ export { runKibanaServer } from './run_kibana_server'; export { runElasticsearch } from './run_elasticsearch'; export type { CreateFtrOptions, CreateFtrParams } from './run_ftr'; export { runFtr, hasTests, assertNoneExcluded } from './run_ftr'; -export { KIBANA_ROOT, KIBANA_FTR_SCRIPT, FUNCTIONAL_CONFIG_PATH, API_CONFIG_PATH } from './paths'; +export { KIBANA_ROOT, KIBANA_FTR_SCRIPT } from './paths'; export { runCli } from './run_cli'; diff --git a/packages/kbn-test/src/functional_tests/lib/paths.ts b/packages/kbn-test/src/functional_tests/lib/paths.ts index 37cd708de1e00eb..75a654fdfc51357 100644 --- a/packages/kbn-test/src/functional_tests/lib/paths.ts +++ b/packages/kbn-test/src/functional_tests/lib/paths.ts @@ -19,6 +19,3 @@ export const KIBANA_EXEC = 'node'; export const KIBANA_EXEC_PATH = resolveRelative('scripts/kibana'); export const KIBANA_ROOT = REPO_ROOT; export const KIBANA_FTR_SCRIPT = resolve(KIBANA_ROOT, 'scripts/functional_test_runner'); -export const PROJECT_ROOT = resolve(__dirname, '../../../../../../'); -export const FUNCTIONAL_CONFIG_PATH = resolve(KIBANA_ROOT, 'test/functional/config'); -export const API_CONFIG_PATH = resolve(KIBANA_ROOT, 'test/api_integration/config'); diff --git a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts index adbb18b5312d0cc..2ee9de4053fef9c 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts +++ b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.ts @@ -17,6 +17,7 @@ interface RunElasticsearchOptions { log: ToolingLog; esFrom?: string; config: Config; + onEarlyExit?: (msg: string) => void; } interface CcsConfig { @@ -92,7 +93,8 @@ export async function runElasticsearch( async function startEsNode( log: ToolingLog, name: string, - config: EsConfig & { transportPort?: number } + config: EsConfig & { transportPort?: number }, + onEarlyExit?: (msg: string) => void ) { const cluster = createTestEsCluster({ clusterName: `cluster-${name}`, @@ -112,6 +114,7 @@ async function startEsNode( }, ], transportPort: config.transportPort, + onEarlyExit, }); await cluster.start(); diff --git a/packages/kbn-test/src/functional_tests/lib/run_ftr.ts b/packages/kbn-test/src/functional_tests/lib/run_ftr.ts index 4c4a7128a05a9c4..b9945adbdfb5603 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_ftr.ts +++ b/packages/kbn-test/src/functional_tests/lib/run_ftr.ts @@ -81,8 +81,8 @@ async function createFtr({ }; } -export async function assertNoneExcluded({ configPath, options }: CreateFtrParams) { - const { config, ftr } = await createFtr({ configPath, options }); +export async function assertNoneExcluded(params: CreateFtrParams) { + const { config, ftr } = await createFtr(params); if (config.get('testRunner')) { // tests with custom test runners are not included in this check @@ -95,21 +95,21 @@ export async function assertNoneExcluded({ configPath, options }: CreateFtrParam } if (stats.testsExcludedByTag.length > 0) { throw new CliError(` - ${stats.testsExcludedByTag.length} tests in the ${configPath} config + ${stats.testsExcludedByTag.length} tests in the ${params.configPath} config are excluded when filtering by the tags run on CI. Make sure that all suites are tagged with one of the following tags: - ${JSON.stringify(options.suiteTags)} + ${JSON.stringify(params.options.suiteTags)} - ${stats.testsExcludedByTag.join('\n - ')} `); } } -export async function runFtr({ configPath, options }: CreateFtrParams) { - const { ftr } = await createFtr({ configPath, options }); +export async function runFtr(params: CreateFtrParams, signal?: AbortSignal) { + const { ftr } = await createFtr(params); - const failureCount = await ftr.run(); + const failureCount = await ftr.run(signal); if (failureCount > 0) { throw new CliError( `${failureCount} functional test ${failureCount === 1 ? 'failure' : 'failures'}` @@ -117,8 +117,8 @@ export async function runFtr({ configPath, options }: CreateFtrParams) { } } -export async function hasTests({ configPath, options }: CreateFtrParams) { - const { ftr, config } = await createFtr({ configPath, options }); +export async function hasTests(params: CreateFtrParams) { + const { ftr, config } = await createFtr(params); if (config.get('testRunner')) { // configs with custom test runners are assumed to always have tests diff --git a/packages/kbn-test/src/functional_tests/lib/run_kibana_server.ts b/packages/kbn-test/src/functional_tests/lib/run_kibana_server.ts index 47d0b1c93b620b0..b5026d397139d85 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_kibana_server.ts +++ b/packages/kbn-test/src/functional_tests/lib/run_kibana_server.ts @@ -31,10 +31,12 @@ export async function runKibanaServer({ procs, config, options, + onEarlyExit, }: { procs: ProcRunner; config: Config; options: { installDir?: string; extraKbnOpts?: string[] }; + onEarlyExit?: (msg: string) => void; }) { const runOptions = config.get('kbnTestServer.runOptions'); const installDir = runOptions.alwaysUseSource ? undefined : options.installDir; @@ -51,6 +53,7 @@ export async function runKibanaServer({ }, cwd: installDir || KIBANA_ROOT, wait: runOptions.wait, + onEarlyExit, }); } diff --git a/packages/kbn-test/src/functional_tests/tasks.ts b/packages/kbn-test/src/functional_tests/tasks.ts index dd9fe4c93016c42..33a49ae2c80d1aa 100644 --- a/packages/kbn-test/src/functional_tests/tasks.ts +++ b/packages/kbn-test/src/functional_tests/tasks.ts @@ -107,14 +107,26 @@ export async function runTests(options: RunTestsParams) { await withProcRunner(log, async (procs) => { const config = await readConfigFile(log, options.esVersion, configPath); + const abortCtrl = new AbortController(); + + const onEarlyExit = (msg: string) => { + log.error(msg); + abortCtrl.abort(); + }; let shutdownEs; try { if (process.env.TEST_ES_DISABLE_STARTUP !== 'true') { - shutdownEs = await runElasticsearch({ ...options, log, config }); + shutdownEs = await runElasticsearch({ ...options, log, config, onEarlyExit }); + if (abortCtrl.signal.aborted) { + return; + } + } + await runKibanaServer({ procs, config, options, onEarlyExit }); + if (abortCtrl.signal.aborted) { + return; } - await runKibanaServer({ procs, config, options }); - await runFtr({ configPath, options: { ...options, log } }); + await runFtr({ configPath, options: { ...options, log } }, abortCtrl.signal); } finally { try { const delay = config.get('kbnTestServer.delayShutdown'); diff --git a/packages/kbn-test/types/ftr_globals/mocha.d.ts b/packages/kbn-test/types/ftr_globals/mocha.d.ts index d5895b40f124532..b9975d4476c905e 100644 --- a/packages/kbn-test/types/ftr_globals/mocha.d.ts +++ b/packages/kbn-test/types/ftr_globals/mocha.d.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { Suite } from 'mocha'; +import 'mocha'; declare module 'mocha' { interface Suite { diff --git a/packages/shared-ux/avatar/solution/BUILD.bazel b/packages/shared-ux/avatar/solution/BUILD.bazel new file mode 100644 index 000000000000000..a253153cb922738 --- /dev/null +++ b/packages/shared-ux/avatar/solution/BUILD.bazel @@ -0,0 +1,146 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library") +load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project") + +PKG_DIRNAME = "solution" +PKG_REQUIRE_NAME = "@kbn/shared-ux-avatar-solution" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.scss", + "src/**/*.mdx", + "src/**/*.svg", + ], + exclude = [ + "**/*.test.*", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +# In this array place runtime dependencies, including other packages and NPM packages +# which must be available for this code to run. +# +# To reference other packages use: +# "//repo/relative/path/to/package" +# eg. "//packages/kbn-utils" +# +# To reference a NPM package use: +# "@npm//name-of-package" +# eg. "@npm//lodash" +RUNTIME_DEPS = [ + "@npm//@elastic/eui", + "@npm//classnames", + "@npm//enzyme", + "@npm//react", + "@npm//url-loader", + "//packages/kbn-i18n-react", + "//packages/kbn-i18n", + "//packages/kbn-shared-ux-utility", +] + +# In this array place dependencies necessary to build the types, which will include the +# :npm_module_types target of other packages and packages from NPM, including @types/* +# packages. +# +# To reference the types for another package use: +# "//repo/relative/path/to/package:npm_module_types" +# eg. "//packages/kbn-utils:npm_module_types" +# +# References to NPM packages work the same as RUNTIME_DEPS +TYPES_DEPS = [ + "@npm//@elastic/eui", + "@npm//@storybook/addon-actions", + "@npm//@types/classnames", + "@npm//@types/enzyme", + "@npm//@types/jest", + "@npm//@types/node", + "@npm//@types/react", + "//packages/kbn-ambient-ui-types", + "//packages/kbn-i18n-react:npm_module_types", + "//packages/kbn-i18n:npm_module_types", + "//packages/kbn-shared-ux-utility:npm_module_types", +] + +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) + +jsts_transpiler( + name = "target_web", + srcs = SRCS, + build_pkg_name = package_name(), + web = True, + additional_args = [ + "--copy-files", + "--quiet" + ], +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + "//:tsconfig.bazel.json", + ], +) + +ts_project( + name = "tsc_types", + args = ['--pretty'], + srcs = SRCS, + deps = TYPES_DEPS, + declaration = True, + emit_declaration_only = True, + out_dir = "target_types", + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_DIRNAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = RUNTIME_DEPS + [":target_node", ":target_web"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [":" + PKG_DIRNAME], +) + +filegroup( + name = "build", + srcs = [":npm_module"], + visibility = ["//visibility:public"], +) + +pkg_npm_types( + name = "npm_module_types", + srcs = SRCS, + deps = [":tsc_types"], + package_name = PKG_REQUIRE_NAME, + tsconfig = ":tsconfig", + visibility = ["//visibility:public"], +) + +filegroup( + name = "build_types", + srcs = [":npm_module_types"], + visibility = ["//visibility:public"], +) diff --git a/packages/shared-ux/avatar/solution/README.mdx b/packages/shared-ux/avatar/solution/README.mdx new file mode 100644 index 000000000000000..841274441f6edbe --- /dev/null +++ b/packages/shared-ux/avatar/solution/README.mdx @@ -0,0 +1,26 @@ +--- +id: sharedUX/Components/KibanaSolutionAvatar +slug: /shared-ux/components/avatar-solution +title: Solution Avatar +summary: A wrapper around `EuiAvatar` tailored for use in Kibana solutions. +tags: ['shared-ux', 'component'] +date: 2022-05-04 +--- + +## Description + +A wrapper around `EuiAvatar` tailored for use in Kibana solutions. + +## Usage + +If using for a known solution, (e.g. one whose logo is in EUI as `logoSomeSolution`), you can simply set the `name` prop: + +```tsx + +``` + +If the name provided does not match a known solution, you *must* set the `iconType` prop: + +```tsx + +``` diff --git a/packages/shared-ux/avatar/solution/jest.config.js b/packages/shared-ux/avatar/solution/jest.config.js new file mode 100644 index 000000000000000..6ca49f67e1dd55d --- /dev/null +++ b/packages/shared-ux/avatar/solution/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/packages/shared-ux/avatar/solution'], +}; diff --git a/packages/shared-ux/avatar/solution/package.json b/packages/shared-ux/avatar/solution/package.json new file mode 100644 index 000000000000000..b0ec8ec947b09a0 --- /dev/null +++ b/packages/shared-ux/avatar/solution/package.json @@ -0,0 +1,8 @@ +{ + "name": "@kbn/shared-ux-avatar-solution", + "private": true, + "version": "1.0.0", + "main": "./target_node/index.js", + "browser": "./target_web/index.js", + "license": "SSPL-1.0 OR Elastic License 2.0" +} diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/__snapshots__/solution_avatar.test.tsx.snap b/packages/shared-ux/avatar/solution/src/__snapshots__/solution_avatar.test.tsx.snap similarity index 54% rename from packages/kbn-shared-ux-components/src/solution_avatar/__snapshots__/solution_avatar.test.tsx.snap rename to packages/shared-ux/avatar/solution/src/__snapshots__/solution_avatar.test.tsx.snap index 9817d7cdd8d45a5..f0666987e0f79a5 100644 --- a/packages/kbn-shared-ux-components/src/solution_avatar/__snapshots__/solution_avatar.test.tsx.snap +++ b/packages/shared-ux/avatar/solution/src/__snapshots__/solution_avatar.test.tsx.snap @@ -8,3 +8,12 @@ exports[`KibanaSolutionAvatar renders 1`] = ` name="Solution" /> `; + +exports[`KibanaSolutionAvatar renders 2`] = ` + +`; diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/assets/texture.svg b/packages/shared-ux/avatar/solution/src/assets/texture.svg similarity index 100% rename from packages/kbn-shared-ux-components/src/solution_avatar/assets/texture.svg rename to packages/shared-ux/avatar/solution/src/assets/texture.svg diff --git a/packages/shared-ux/avatar/solution/src/index.tsx b/packages/shared-ux/avatar/solution/src/index.tsx new file mode 100644 index 000000000000000..c2c9613bab87dfa --- /dev/null +++ b/packages/shared-ux/avatar/solution/src/index.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 React from 'react'; +import { withSuspense } from '@kbn/shared-ux-utility'; + +export type { KibanaSolutionAvatarProps } from './solution_avatar'; + +/** + * The Lazily-loaded `KibanaSolutionAvatar` component. Consumers should use `React.Suspense` or + * the withSuspense` HOC to load this component. + */ +export const KibanaSolutionAvatarLazy = React.lazy(() => + import('./solution_avatar').then(({ KibanaSolutionAvatar }) => ({ + default: KibanaSolutionAvatar, + })) +); + +/** + * A `KibanaSolutionAvatar` component that is wrapped by the `withSuspense` HOC. This component can + * be used directly by consumers and will load the `KibanaPageTemplateSolutionNavAvatarLazy` component lazily with + * a predefined fallback and error boundary. + */ +export const KibanaSolutionAvatar = withSuspense(KibanaSolutionAvatarLazy); diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.scss b/packages/shared-ux/avatar/solution/src/solution_avatar.scss similarity index 100% rename from packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.scss rename to packages/shared-ux/avatar/solution/src/solution_avatar.scss diff --git a/packages/shared-ux/avatar/solution/src/solution_avatar.stories.tsx b/packages/shared-ux/avatar/solution/src/solution_avatar.stories.tsx new file mode 100644 index 000000000000000..b47ff7c837f2411 --- /dev/null +++ b/packages/shared-ux/avatar/solution/src/solution_avatar.stories.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { KibanaSolutionAvatar, IconTypeProps, KnownSolutionProps } from './solution_avatar'; + +export default { + title: 'Solution Avatar', + description: 'A wrapper around EuiAvatar, specifically to stylize Elastic Solutions', +}; + +const argTypes = { + size: { + control: 'select', + options: ['s', 'm', 'l', 'xl', 'xxl'], + defaultValue: 'xxl', + }, +}; + +type KnownSolutionParams = Pick; + +export const SolutionAvatar = (params: KnownSolutionParams) => { + return ; +}; + +SolutionAvatar.argTypes = { + name: { + control: 'select', + options: ['Cloud', 'Elastic', 'Kibana', 'Observability', 'Security', 'Enterprise Search'], + defaultValue: 'Elastic', + }, + ...argTypes, +}; + +type IconTypeParams = Pick; + +export const IconTypeAvatar = (params: IconTypeParams) => { + return ; +}; + +IconTypeAvatar.argTypes = { + iconType: { + control: 'select', + options: [ + 'logoCloud', + 'logoElastic', + 'logoElasticsearch', + 'logoElasticStack', + 'logoKibana', + 'logoObservability', + 'logoSecurity', + 'logoSiteSearch', + 'logoWorkplaceSearch', + 'machineLearningApp', + 'managementApp', + ], + defaultValue: 'logoElastic', + }, + name: { + control: 'text', + defaultValue: 'Solution Name', + }, + ...argTypes, +}; diff --git a/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.test.tsx b/packages/shared-ux/avatar/solution/src/solution_avatar.test.tsx similarity index 68% rename from packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.test.tsx rename to packages/shared-ux/avatar/solution/src/solution_avatar.test.tsx index 7a8b20c3f8d648e..ab7c675b24e0d44 100644 --- a/packages/kbn-shared-ux-components/src/solution_avatar/solution_avatar.test.tsx +++ b/packages/shared-ux/avatar/solution/src/solution_avatar.test.tsx @@ -12,7 +12,9 @@ import { KibanaSolutionAvatar } from './solution_avatar'; describe('KibanaSolutionAvatar', () => { test('renders', () => { - const component = shallow(); - expect(component).toMatchSnapshot(); + const nameAndIcon = shallow(); + expect(nameAndIcon).toMatchSnapshot(); + const nameOnly = shallow(); + expect(nameOnly).toMatchSnapshot(); }); }); diff --git a/packages/shared-ux/avatar/solution/src/solution_avatar.tsx b/packages/shared-ux/avatar/solution/src/solution_avatar.tsx new file mode 100644 index 000000000000000..0c38652a273953d --- /dev/null +++ b/packages/shared-ux/avatar/solution/src/solution_avatar.tsx @@ -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 './solution_avatar.scss'; + +import React from 'react'; +import classNames from 'classnames'; + +import { DistributiveOmit, EuiAvatar, EuiAvatarProps, IconType } from '@elastic/eui'; + +import { SolutionNameType } from './types'; + +export type KnownSolutionProps = DistributiveOmit & { + /** + * Any EuiAvatar size available, or `xxl` for custom large, brand-focused version + */ + size?: EuiAvatarProps['size'] | 'xxl'; + name: SolutionNameType; +}; + +export type IconTypeProps = DistributiveOmit & { + /** + * Any EuiAvatar size available, or `xxl` for custom large, brand-focused version + */ + size?: EuiAvatarProps['size'] | 'xxl'; + name?: string; + iconType: IconType; +}; + +const isKnown = (props: any): props is KnownSolutionProps => { + return typeof props.iconType === 'undefined'; +}; + +export type KibanaSolutionAvatarProps = KnownSolutionProps | IconTypeProps; + +/** + * Applies extra styling to a typical EuiAvatar. + * The `name` value will be appended to 'logo' to configure the `iconType` unless `iconType` is provided. + */ +export const KibanaSolutionAvatar = (props: KibanaSolutionAvatarProps) => { + const { className, size, ...rest } = props; + + // If the name is a known solution, use the name to set the correct IconType. + // Create an empty object so `iconType` remains undefined or inherited from `props`. + const icon: { + iconType?: IconType; + } = {}; + + if (isKnown(props)) { + icon.iconType = `logo${props.name.replace(/\s+/g, '')}`; + } + + return ( + // @ts-ignore Complains about ExclusiveUnion between `iconSize` and `iconType`, but works fine + + ); +}; diff --git a/packages/shared-ux/avatar/solution/src/types.ts b/packages/shared-ux/avatar/solution/src/types.ts new file mode 100644 index 000000000000000..bf0ad682e30067e --- /dev/null +++ b/packages/shared-ux/avatar/solution/src/types.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +// Manual, exhaustive list at present. This was attempted dynamically using Typescript Template Literals and +// the computation cost exceeded the benefit. By enumerating them manually, we reduce the complexity of TS +// checking at the expense of not being dynamic against a very, very static list. +// +// The only consequence is requiring a solution name without a space, (e.g. `ElasticStack`) until it's added +// here. That's easy to do in the very unlikely event that ever happens. +export type SolutionNameType = + | 'App Search' + | 'Beats' + | 'Business Analytics' + | 'Cloud' + | 'Cloud Enterprise' + | 'Code' + | 'Elastic' + | 'Elastic Stack' + | 'Elasticsearch' + | 'Enterprise Search' + | 'Logstash' + | 'Maps' + | 'Metrics' + | 'Observability' + | 'Security' + | 'Site Search' + | 'Uptime' + | 'Webhook' + | 'Workplace Search'; diff --git a/packages/shared-ux/avatar/solution/tsconfig.json b/packages/shared-ux/avatar/solution/tsconfig.json new file mode 100644 index 000000000000000..93076efae5d7ca8 --- /dev/null +++ b/packages/shared-ux/avatar/solution/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../../tsconfig.bazel.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "target_types", + "rootDir": "src", + "stripInternal": false, + "types": [ + "jest", + "node", + "react", + "@kbn/ambient-ui-types" + ] + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/shared-ux/link/redirect_app/BUILD.bazel b/packages/shared-ux/link/redirect_app/BUILD.bazel new file mode 100644 index 000000000000000..861b9aa277db9f8 --- /dev/null +++ b/packages/shared-ux/link/redirect_app/BUILD.bazel @@ -0,0 +1,140 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library") +load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project") + +PKG_DIRNAME = "redirect_app" +PKG_REQUIRE_NAME = "@kbn/shared-ux-link-redirect-app" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.mdx", + ], + exclude = [ + "**/*.test.*", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +# In this array place runtime dependencies, including other packages and NPM packages +# which must be available for this code to run. +# +# To reference other packages use: +# "//repo/relative/path/to/package" +# eg. "//packages/kbn-utils" +# +# To reference a NPM package use: +# "@npm//name-of-package" +# eg. "@npm//lodash" +RUNTIME_DEPS = [ + "@npm//@elastic/eui", + "@npm//@storybook/addon-actions", + "@npm//react-use", + "@npm//react", + "@npm//rxjs", + "//packages/kbn-shared-ux-utility", +] + +# In this array place dependencies necessary to build the types, which will include the +# :npm_module_types target of other packages and packages from NPM, including @types/* +# packages. +# +# To reference the types for another package use: +# "//repo/relative/path/to/package:npm_module_types" +# eg. "//packages/kbn-utils:npm_module_types" +# +# References to NPM packages work the same as RUNTIME_DEPS +TYPES_DEPS = [ + "@npm//@elastic/eui", + "@npm//@storybook/addon-actions", + "@npm//@types/jest", + "@npm//@types/node", + "@npm//@types/react", + "@npm//rxjs", + "@npm//react-use", + "//packages/kbn-ambient-ui-types", + "//packages/kbn-shared-ux-utility:npm_module_types", +] + +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) + +jsts_transpiler( + name = "target_web", + srcs = SRCS, + build_pkg_name = package_name(), + web = True, + additional_args = [ + "--copy-files", + "--quiet" + ], +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + "//:tsconfig.bazel.json", + ], +) + +ts_project( + name = "tsc_types", + args = ['--pretty'], + srcs = SRCS, + deps = TYPES_DEPS, + declaration = True, + emit_declaration_only = True, + out_dir = "target_types", + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_DIRNAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = RUNTIME_DEPS + [":target_node", ":target_web"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [":" + PKG_DIRNAME], +) + +filegroup( + name = "build", + srcs = [":npm_module"], + visibility = ["//visibility:public"], +) + +pkg_npm_types( + name = "npm_module_types", + srcs = SRCS, + deps = [":tsc_types"], + package_name = PKG_REQUIRE_NAME, + tsconfig = ":tsconfig", + visibility = ["//visibility:public"], +) + +filegroup( + name = "build_types", + srcs = [":npm_module_types"], + visibility = ["//visibility:public"], +) diff --git a/packages/shared-ux/link/redirect_app/README.mdx b/packages/shared-ux/link/redirect_app/README.mdx new file mode 100644 index 000000000000000..8e2eada760ea2a2 --- /dev/null +++ b/packages/shared-ux/link/redirect_app/README.mdx @@ -0,0 +1,86 @@ +--- +id: sharedUX/Components/AppLink +slug: /shared-ux/components/redirect-app-links +title: Redirect App Links +summary: A component for redirecting links contained within it to the appropriate Kibana solution without a page refresh. +tags: ['shared-ux', 'component'] +date: 2022-05-04 +--- + +## Description + +This component is an "area of effect" component, which produces a container that intercepts actions for specific elements within it. In this case, the container intercepts clicks on anchor elements and redirects them to Kibana solutions without a page refresh. + +## Pure Component + +The pure component allows you create a container to intercept clicks without contextual services, (e.g. Kibana Core). This likely does not have much utility for solutions in Kibana, but rather is useful for shared components where we want to ensure clicks are redirected correctly. + +```tsx +import { RedirectAppLinksComponent as RedirectAppLinks } from '@kbn/shared-ux-links-redirect-app'; + + { ... }}> + Go to another-app + +``` + +## Connected Component + +The connected component uses a React Context to access services that provide the current app id and a function to navigate to a new url. This is useful in that a solution can wrap their entire application in the context and use `RedirectAppLinks` in specific areas. + +```tsx +import { RedirectAppLinksContainer as RedirectAppLinks, RedirectAppLinksProvider } from '@kbn/shared-ux-links-redirect-app'; + + { ... }}> + . + {/* other components that don't need to redirect */} + . + + Go to another-app + + . + . + . + +``` + +You can also use the Kibana provider: + +```tsx +import { + RedirectAppLinksContainer as RedirectAppLinks, + RedirectAppLinksKibanaProvider as RedirectAppLinksProvider +} from '@kbn/shared-ux-links-redirect-app'; + + + . + {/* other components that don't need to redirect */} + . + + Go to another-app + + . + . + +``` + +## Top-level Component + +This is the component is likely the most useful to solutions in Kibana. It assumes an entire solution needs this redirect functionality, and combines the context provider with the container. This top-level component can be used with either pure props or with Kibana services. + +```tsx +import { RedirectAppLinks } from '@kbn/shared-ux-links-redirect-app'; + + { ... }}> + . + Go to another-app + . + + +{/* OR */} + + + . + Go to another-app + . + +``` \ No newline at end of file diff --git a/packages/shared-ux/link/redirect_app/jest.config.js b/packages/shared-ux/link/redirect_app/jest.config.js new file mode 100644 index 000000000000000..5f564a9709d0cfd --- /dev/null +++ b/packages/shared-ux/link/redirect_app/jest.config.js @@ -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. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/packages/shared-ux/link/redirect_app'], + verbose: true, +}; diff --git a/packages/shared-ux/link/redirect_app/package.json b/packages/shared-ux/link/redirect_app/package.json new file mode 100644 index 000000000000000..6deb187dcec2a91 --- /dev/null +++ b/packages/shared-ux/link/redirect_app/package.json @@ -0,0 +1,8 @@ +{ + "name": "@kbn/shared-ux-link-redirect-app", + "private": true, + "version": "1.0.0", + "main": "./target_node/index.js", + "browser": "./target_web/index.js", + "license": "SSPL-1.0 OR Elastic License 2.0" +} diff --git a/packages/kbn-shared-ux-components/src/redirect_app_links/click_handler.test.ts b/packages/shared-ux/link/redirect_app/src/click_handler.test.ts similarity index 84% rename from packages/kbn-shared-ux-components/src/redirect_app_links/click_handler.test.ts rename to packages/shared-ux/link/redirect_app/src/click_handler.test.ts index dd26443eed171dc..c46b93bb67aafd0 100644 --- a/packages/kbn-shared-ux-components/src/redirect_app_links/click_handler.test.ts +++ b/packages/shared-ux/link/redirect_app/src/click_handler.test.ts @@ -7,7 +7,7 @@ */ import { MouseEvent } from 'react'; -import { createNavigateToUrlClickHandler } from './click_handler'; +import { navigateToUrlClickHandler } from './click_handler'; const createLink = ({ href = '/base-path/app/targetApp', @@ -43,27 +43,59 @@ const createEvent = ({ type NavigateToURLFn = (url: string) => Promise; -describe('createNavigateToUrlClickHandler', () => { +describe('navigateToUrlClickHandler', () => { let container: HTMLElement; let navigateToUrl: jest.MockedFunction; + const currentAppId = 'abc123'; - const createHandler = () => - createNavigateToUrlClickHandler({ + const handler = (event: MouseEvent): void => { + navigateToUrlClickHandler({ + event, + currentAppId, container, navigateToUrl, }); + }; beforeEach(() => { container = document.createElement('div'); navigateToUrl = jest.fn(); }); - it('calls `navigateToUrl` with the link url', () => { - const handler = createHandler(); + it("doesn't call `navigateToUrl` without a container", () => { + const event = createEvent({ + target: createLink({ href: '/base-path/app/targetApp' }), + }); + navigateToUrlClickHandler({ + event, + currentAppId, + container: null, + navigateToUrl, + }); + + expect(event.preventDefault).toHaveBeenCalledTimes(0); + }); + + it("doesn't call `navigateToUrl` without a `currentAppId`", () => { const event = createEvent({ target: createLink({ href: '/base-path/app/targetApp' }), }); + + navigateToUrlClickHandler({ + event, + container, + navigateToUrl, + }); + + expect(event.preventDefault).toHaveBeenCalledTimes(0); + }); + + it('calls `navigateToUrl` with the link url', () => { + const event = createEvent({ + target: createLink({ href: '/base-path/app/targetApp' }), + }); + handler(event); expect(event.preventDefault).toHaveBeenCalledTimes(1); @@ -71,13 +103,12 @@ describe('createNavigateToUrlClickHandler', () => { }); it('is triggered if a non-link target has a parent link', () => { - const handler = createHandler(); - const link = createLink(); const target = document.createElement('span'); link.appendChild(target); const event = createEvent({ target }); + handler(event); expect(event.preventDefault).toHaveBeenCalledTimes(1); @@ -85,13 +116,12 @@ describe('createNavigateToUrlClickHandler', () => { }); it('is not triggered if a non-link target has no parent link', () => { - const handler = createHandler(); - const parent = document.createElement('div'); const target = document.createElement('span'); parent.appendChild(target); const event = createEvent({ target }); + handler(event); expect(event.preventDefault).not.toHaveBeenCalled(); @@ -99,11 +129,10 @@ describe('createNavigateToUrlClickHandler', () => { }); it('is not triggered when the link has no href', () => { - const handler = createHandler(); - const event = createEvent({ target: createLink({ href: '' }), }); + handler(event); expect(event.preventDefault).not.toHaveBeenCalled(); @@ -111,11 +140,10 @@ describe('createNavigateToUrlClickHandler', () => { }); it('is only triggered when the link does not have an external target', () => { - const handler = createHandler(); - let event = createEvent({ target: createLink({ target: '_blank' }), }); + handler(event); expect(event.preventDefault).not.toHaveBeenCalled(); @@ -124,6 +152,7 @@ describe('createNavigateToUrlClickHandler', () => { event = createEvent({ target: createLink({ target: 'some-target' }), }); + handler(event); expect(event.preventDefault).not.toHaveBeenCalled(); @@ -132,6 +161,7 @@ describe('createNavigateToUrlClickHandler', () => { event = createEvent({ target: createLink({ target: '_self' }), }); + handler(event); expect(event.preventDefault).toHaveBeenCalledTimes(1); @@ -140,6 +170,7 @@ describe('createNavigateToUrlClickHandler', () => { event = createEvent({ target: createLink({ target: '' }), }); + handler(event); expect(event.preventDefault).toHaveBeenCalledTimes(1); @@ -147,11 +178,10 @@ describe('createNavigateToUrlClickHandler', () => { }); it('is only triggered from left clicks', () => { - const handler = createHandler(); - let event = createEvent({ button: 1, }); + handler(event); expect(event.preventDefault).not.toHaveBeenCalled(); @@ -160,6 +190,7 @@ describe('createNavigateToUrlClickHandler', () => { event = createEvent({ button: 12, }); + handler(event); expect(event.preventDefault).not.toHaveBeenCalled(); @@ -168,6 +199,7 @@ describe('createNavigateToUrlClickHandler', () => { event = createEvent({ button: 0, }); + handler(event); expect(event.preventDefault).toHaveBeenCalledTimes(1); @@ -175,11 +207,10 @@ describe('createNavigateToUrlClickHandler', () => { }); it('is not triggered if the event default is prevented', () => { - const handler = createHandler(); - let event = createEvent({ defaultPrevented: true, }); + handler(event); expect(event.preventDefault).not.toHaveBeenCalled(); @@ -188,6 +219,7 @@ describe('createNavigateToUrlClickHandler', () => { event = createEvent({ defaultPrevented: false, }); + handler(event); expect(event.preventDefault).toHaveBeenCalledTimes(1); @@ -195,15 +227,15 @@ describe('createNavigateToUrlClickHandler', () => { }); it('is not triggered if any modifier key is pressed', () => { - const handler = createHandler(); - let event = createEvent({ modifierKey: true }); + handler(event); expect(event.preventDefault).not.toHaveBeenCalled(); expect(navigateToUrl).not.toHaveBeenCalled(); event = createEvent({ modifierKey: false }); + handler(event); expect(event.preventDefault).toHaveBeenCalledTimes(1); diff --git a/packages/shared-ux/link/redirect_app/src/click_handler.ts b/packages/shared-ux/link/redirect_app/src/click_handler.ts new file mode 100644 index 000000000000000..8c94aa0033f2b00 --- /dev/null +++ b/packages/shared-ux/link/redirect_app/src/click_handler.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 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 { MouseEvent } from 'react'; +import { getClosestLink, hasActiveModifierKey } from '@kbn/shared-ux-utility'; +import { NavigateToUrl } from './types'; + +interface CreateCrossAppClickHandlerOptions { + event: MouseEvent; + navigateToUrl: NavigateToUrl; + container: HTMLElement | null; + currentAppId?: string; +} + +/** + * Constructs a click handler that will redirect the user using `navigateToUrl` if the + * correct conditions are met. + */ +export const navigateToUrlClickHandler = ({ + event, + container, + navigateToUrl, + currentAppId, +}: CreateCrossAppClickHandlerOptions) => { + if (!container || !currentAppId) { + return; + } + + // see https://github.com/DefinitelyTyped/DefinitelyTyped/pull/12239 + const target = event.target as HTMLElement; + + const link = getClosestLink(target, container); + + if (!link) { + return; + } + + const isNotEmptyHref = link.href; + const hasNoTarget = link.target === '' || link.target === '_self'; + const isLeftClickOnly = event.button === 0; + + if ( + isNotEmptyHref && + hasNoTarget && + isLeftClickOnly && + !event.defaultPrevented && + !hasActiveModifierKey(event) + ) { + event.preventDefault(); + navigateToUrl(link.href); + } +}; diff --git a/packages/shared-ux/link/redirect_app/src/index.tsx b/packages/shared-ux/link/redirect_app/src/index.tsx new file mode 100644 index 000000000000000..5efb99cc4866493 --- /dev/null +++ b/packages/shared-ux/link/redirect_app/src/index.tsx @@ -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. + */ + +export { RedirectAppLinks as RedirectAppLinksContainer } from './redirect_app_links'; +export { RedirectAppLinks as RedirectAppLinksComponent } from './redirect_app_links'; +export { RedirectAppLinksKibanaProvider, RedirectAppLinksProvider } from './services'; + +import React, { FC } from 'react'; +import { RedirectAppLinks as RedirectAppLinksContainer } from './redirect_app_links'; +import { + Services, + KibanaServices, + RedirectAppLinksKibanaProvider, + RedirectAppLinksProvider, +} from './services'; + +const isKibanaContract = (services: any): services is KibanaServices => { + return typeof services.coreStart !== 'undefined'; +}; + +/** + * This component composes `RedirectAppLinksContainer` with either `RedirectAppLinksProvider` or + * `RedirectAppLinksKibanaProvider` based on the services provided, creating a single component + * with which consumers can wrap their components or solutions. + */ +export const RedirectAppLinks: FC = ({ children, ...services }) => { + const container = {children}; + + return isKibanaContract(services) ? ( + {container} + ) : ( + {container} + ); +}; diff --git a/packages/shared-ux/link/redirect_app/src/redirect_app_links.component.tsx b/packages/shared-ux/link/redirect_app/src/redirect_app_links.component.tsx new file mode 100644 index 000000000000000..477471fe71824cd --- /dev/null +++ b/packages/shared-ux/link/redirect_app/src/redirect_app_links.component.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 React, { useRef, MouseEventHandler, useCallback } from 'react'; +import type { HTMLAttributes, DetailedHTMLProps, FC } from 'react'; + +import { navigateToUrlClickHandler } from './click_handler'; +import { NavigateToUrl } from './types'; + +export interface Props extends DetailedHTMLProps, HTMLDivElement> { + navigateToUrl: NavigateToUrl; + currentAppId?: string | undefined; +} + +/** + * Utility component that will intercept click events on children anchor (``) elements to call + * `navigateToUrl` with the link's href. This will trigger SPA friendly navigation when the link points + * to a valid Kibana app. + * + * @example + * ```tsx + * { ... }}> + * Go to another-app + * + * ``` + */ +export const RedirectAppLinks: FC = ({ + children, + navigateToUrl, + currentAppId, + ...otherProps +}) => { + const containerRef = useRef(null); + + const handleClick: MouseEventHandler = useCallback( + (event) => + navigateToUrlClickHandler({ + event, + currentAppId, + navigateToUrl, + container: containerRef.current, + }), + [currentAppId, navigateToUrl] + ); + + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events +
+ {children} +
+ ); +}; diff --git a/packages/shared-ux/link/redirect_app/src/redirect_app_links.stories.tsx b/packages/shared-ux/link/redirect_app/src/redirect_app_links.stories.tsx new file mode 100644 index 000000000000000..9bb3d0d9782d492 --- /dev/null +++ b/packages/shared-ux/link/redirect_app/src/redirect_app_links.stories.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React from 'react'; + +import { action } from '@storybook/addon-actions'; +import { RedirectAppLinks } from '.'; +import mdx from '../README.mdx'; + +export default { + title: 'Redirect App Links', + description: + 'An "area of effect" component which intercepts clicks on anchor elements and redirects them to Kibana solutions without a page refresh.', + parameters: { + docs: { + page: mdx, + }, + }, +}; + +export const Component = () => { + const navigateToUrl = async (url: string) => { + action('navigateToUrl')(url); + }; + + const currentAppId = 'abc123'; + + return ( + <> + + + + + Button with URL + + + + + Button without URL + + + + + + + + Button outside RedirectAppLinks + + + + + ); +}; diff --git a/packages/shared-ux/link/redirect_app/src/redirect_app_links.test.tsx b/packages/shared-ux/link/redirect_app/src/redirect_app_links.test.tsx new file mode 100644 index 000000000000000..1bb3875aec7aed8 --- /dev/null +++ b/packages/shared-ux/link/redirect_app/src/redirect_app_links.test.tsx @@ -0,0 +1,292 @@ +/* + * 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. + */ + +/* eslint-disable jsx-a11y/click-events-have-key-events */ +import React, { MouseEvent } from 'react'; +import { mount as enzymeMount, ReactWrapper } from 'enzyme'; + +import { RedirectAppLinksKibanaProvider, RedirectAppLinksProvider } from './services'; +import { RedirectAppLinks } from './redirect_app_links'; +import { RedirectAppLinks as ComposedWrapper } from '.'; +import { Observable } from 'rxjs'; + +export type UnmountCallback = () => void; +export type MountPoint = (element: T) => UnmountCallback; +type Mount = ( + node: React.ReactElement +) => ReactWrapper, React.Component<{}, {}, any>>; + +const commonTests = (name: string, mount: Mount, navigateToUrl: jest.Mock) => { + beforeEach(() => { + navigateToUrl.mockReset(); + }); + + describe(`RedirectAppLinks with ${name}`, () => { + it('intercept click events on children link elements', () => { + let event: MouseEvent; + const component = mount( +
{ + event = e; + }} + > + +
+ content +
+
+
+ ); + + component.find('a').simulate('click', { button: 0, defaultPrevented: false }); + expect(navigateToUrl).toHaveBeenCalledTimes(1); + expect(event!.defaultPrevented).toBe(true); + }); + + it('intercept click events on children inside link elements', async () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + + + +
+ ); + + component.find('span').simulate('click', { button: 0, defaultPrevented: false }); + + expect(navigateToUrl).toHaveBeenCalledTimes(1); + expect(event!.defaultPrevented).toBe(true); + }); + + it('does not intercept click events when the target is not inside a link', () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + + + content + + +
+ ); + + component.find('span').simulate('click', { button: 0, defaultPrevented: false }); + + expect(navigateToUrl).not.toHaveBeenCalled(); + expect(event!.defaultPrevented).toBe(false); + }); + + it('does not intercept click events when the link has an external target', () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + + + content + + +
+ ); + + component.find('a').simulate('click', { button: 0, defaultPrevented: false }); + + expect(navigateToUrl).not.toHaveBeenCalled(); + expect(event!.defaultPrevented).toBe(false); + }); + + it('does not intercept click events when the event is already defaultPrevented', () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + + + e.preventDefault()}>content + + +
+ ); + + component.find('span').simulate('click', { button: 0, defaultPrevented: false }); + + expect(navigateToUrl).not.toHaveBeenCalled(); + expect(event!.defaultPrevented).toBe(true); + }); + + it('does not intercept click events when the event propagation is stopped', () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + + e.stopPropagation()}> + content + + +
+ ); + + component.find('a').simulate('click', { button: 0, defaultPrevented: false }); + + expect(navigateToUrl).not.toHaveBeenCalled(); + expect(event!).toBe(undefined); + }); + + it('does not intercept click events when the event is not triggered from the left button', () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + +
+ content +
+
+
+ ); + + component.find('a').simulate('click', { button: 1, defaultPrevented: false }); + + expect(navigateToUrl).not.toHaveBeenCalled(); + expect(event!.defaultPrevented).toBe(false); + }); + + it('does not intercept click events when the event has a modifier key enabled', () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + +
+ content +
+
+
+ ); + + component.find('a').simulate('click', { button: 0, ctrlKey: true, defaultPrevented: false }); + + expect(navigateToUrl).not.toHaveBeenCalled(); + expect(event!.defaultPrevented).toBe(false); + }); + }); +}; + +const targetedTests = (name: string, mount: Mount, navigateToUrl: jest.Mock) => { + beforeEach(() => { + navigateToUrl.mockReset(); + }); + + describe(`${name} with isolated areas of effect`, () => { + it(`does not intercept click events when the link is a parent of the container`, () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + + + content + + +
+ ); + + component.find('span').simulate('click', { button: 0, defaultPrevented: false }); + + expect(navigateToUrl).not.toHaveBeenCalled(); + expect(event!.defaultPrevented).toBe(false); + }); + }); +}; + +describe('RedirectAppLinks', () => { + const navigateToUrl = jest.fn(); + + beforeEach(() => { + navigateToUrl.mockReset(); + }); + + const kibana = { + coreStart: { + application: { + currentAppId$: new Observable((subscriber) => { + subscriber.next('123'); + }), + navigateToUrl, + }, + }, + }; + + const services = { + currentAppId: 'abc123', + navigateToUrl, + }; + + const provider = (node: React.ReactElement) => + enzymeMount({node}); + + const kibanaProvider = (node: React.ReactElement) => + enzymeMount( + {node} + ); + + const composedProvider = (node: React.ReactElement) => + enzymeMount({node}); + + const composedKibanaProvider = (node: React.ReactElement) => + enzymeMount({node}); + + describe('Test all Providers', () => { + commonTests('RedirectAppLinksProvider', provider, navigateToUrl); + targetedTests('RedirectAppLinksProvider', provider, navigateToUrl); + commonTests('RedirectAppLinksKibanaProvider', kibanaProvider, navigateToUrl); + targetedTests('RedirectAppLinksKibanaProvider', kibanaProvider, navigateToUrl); + commonTests('Provider Props', composedProvider, navigateToUrl); + commonTests('Kibana Props', composedKibanaProvider, navigateToUrl); + }); +}); diff --git a/packages/shared-ux/link/redirect_app/src/redirect_app_links.tsx b/packages/shared-ux/link/redirect_app/src/redirect_app_links.tsx new file mode 100644 index 000000000000000..1e805ad4475b60a --- /dev/null +++ b/packages/shared-ux/link/redirect_app/src/redirect_app_links.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 React from 'react'; + +import { useServices } from './services'; +import { + RedirectAppLinks as Component, + Props as ComponentProps, +} from './redirect_app_links.component'; + +type Props = Omit; + +/** + * A service-enabled component that provides Kibana-specific functionality to the `RedirectAppLinks` + * pure component. + * + * @example + * ```tsx + * + * Go to another-app + * + * ``` + */ +export const RedirectAppLinks = (props: Props) => ; diff --git a/packages/shared-ux/link/redirect_app/src/services.tsx b/packages/shared-ux/link/redirect_app/src/services.tsx new file mode 100644 index 000000000000000..22bc5a5cd0c55ee --- /dev/null +++ b/packages/shared-ux/link/redirect_app/src/services.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 React, { FC, useContext } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { Observable } from 'rxjs'; +import { NavigateToUrl } from './types'; + +/** + * Contextual services for this component. + */ +export interface Services { + navigateToUrl: NavigateToUrl; + currentAppId?: string; +} + +const RedirectAppLinksContext = React.createContext(null); + +/** + * Contextual services Provider. + */ +export const RedirectAppLinksProvider: FC = ({ children, ...services }) => { + return ( + + {children} + + ); +}; + +/** + * Kibana-specific contextual services to be adapted for this component. + */ +export interface KibanaServices { + coreStart: { + application: { + currentAppId$: Observable; + navigateToUrl: NavigateToUrl; + }; + }; +} + +/** + * Kibana-specific contextual services Provider. + */ +export const RedirectAppLinksKibanaProvider: FC = ({ children, coreStart }) => { + const { navigateToUrl, currentAppId$ } = coreStart.application; + const currentAppId = useObservable(currentAppId$, undefined); + + return ( + + {children} + + ); +}; + +/** + * React hook for accessing pre-wired services. + */ +export function useServices() { + const context = useContext(RedirectAppLinksContext); + + if (!context) { + throw new Error( + 'RedirectAppLinksContext is missing. Ensure your component or React root is wrapped with RedirectAppLinksProvider.' + ); + } + + return context; +} diff --git a/packages/shared-ux/link/redirect_app/src/types.ts b/packages/shared-ux/link/redirect_app/src/types.ts new file mode 100644 index 000000000000000..2c27ccde84d67eb --- /dev/null +++ b/packages/shared-ux/link/redirect_app/src/types.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 type NavigateToUrl = (url: string) => Promise | void; diff --git a/packages/shared-ux/link/redirect_app/tsconfig.json b/packages/shared-ux/link/redirect_app/tsconfig.json new file mode 100644 index 000000000000000..93076efae5d7ca8 --- /dev/null +++ b/packages/shared-ux/link/redirect_app/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../../tsconfig.bazel.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "target_types", + "rootDir": "src", + "stripInternal": false, + "types": [ + "jest", + "node", + "react", + "@kbn/ambient-ui-types" + ] + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/shared-ux/page/analytics_no_data/BUILD.bazel b/packages/shared-ux/page/analytics_no_data/BUILD.bazel new file mode 100644 index 000000000000000..ad687fe8a220b18 --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/BUILD.bazel @@ -0,0 +1,139 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library") +load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project") + +PKG_DIRNAME = "analytics_no_data" +PKG_REQUIRE_NAME = "@kbn/shared-ux-page-analytics-no-data" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.mdx", + ], + exclude = [ + "**/*.test.*", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +# In this array place runtime dependencies, including other packages and NPM packages +# which must be available for this code to run. +# +# To reference other packages use: +# "//repo/relative/path/to/package" +# eg. "//packages/kbn-utils" +# +# To reference a NPM package use: +# "@npm//name-of-package" +# eg. "@npm//lodash" +RUNTIME_DEPS = [ + "@npm//react", + "@npm//rxjs", + "//packages/kbn-i18n", + "//packages/kbn-shared-ux-services", + "//packages/kbn-shared-ux-components", + "//packages/kbn-shared-ux-storybook" +] + +# In this array place dependencies necessary to build the types, which will include the +# :npm_module_types target of other packages and packages from NPM, including @types/* +# packages. +# +# To reference the types for another package use: +# "//repo/relative/path/to/package:npm_module_types" +# eg. "//packages/kbn-utils:npm_module_types" +# +# References to NPM packages work the same as RUNTIME_DEPS +TYPES_DEPS = [ + "@npm//@types/node", + "@npm//@types/jest", + "@npm//@types/react", + "//packages/kbn-i18n:npm_module_types", + "//packages/kbn-shared-ux-services:npm_module_types", + "//packages/kbn-shared-ux-storybook:npm_module_types", + "//packages/kbn-shared-ux-components:npm_module_types", + "//packages/kbn-ambient-ui-types", +] + +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) + +jsts_transpiler( + name = "target_web", + srcs = SRCS, + build_pkg_name = package_name(), + web = True, + additional_args = [ + "--copy-files", + "--quiet" + ], +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + "//:tsconfig.bazel.json", + ], +) + +ts_project( + name = "tsc_types", + args = ['--pretty'], + srcs = SRCS, + deps = TYPES_DEPS, + declaration = True, + emit_declaration_only = True, + out_dir = "target_types", + root_dir = "src", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_DIRNAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = RUNTIME_DEPS + [":target_node", ":target_web"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [":" + PKG_DIRNAME], +) + +filegroup( + name = "build", + srcs = [":npm_module"], + visibility = ["//visibility:public"], +) + +pkg_npm_types( + name = "npm_module_types", + srcs = SRCS, + deps = [":tsc_types"], + package_name = PKG_REQUIRE_NAME, + tsconfig = ":tsconfig", + visibility = ["//visibility:public"], +) + +filegroup( + name = "build_types", + srcs = [":npm_module_types"], + visibility = ["//visibility:public"], +) diff --git a/packages/shared-ux/page/analytics_no_data/README.mdx b/packages/shared-ux/page/analytics_no_data/README.mdx new file mode 100644 index 000000000000000..ab8cf8d1cb063b9 --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/README.mdx @@ -0,0 +1,16 @@ +--- +id: sharedUX/Components/AnalyticsNoDataPage +slug: /shared-ux/components/analytics-no-data-page +title: Analytics "No Data" Page +summary: An entire page that can be displayed when Kibana "has no data", specifically for Analytics. +tags: ['shared-ux', 'component'] +date: 2021-12-28 +--- + +## Description + +This is an Analytics-specific version of `KibanaNoDataPage`, which defaults most of the fields to give a consistent set of terms for Analytics solutions. + +## EUI Promotion Status + +This component is not currently considered for promotion to EUI. diff --git a/packages/shared-ux/page/analytics_no_data/jest.config.js b/packages/shared-ux/page/analytics_no_data/jest.config.js new file mode 100644 index 000000000000000..76067f82881f770 --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../..', + roots: ['/packages/shared-ux/page/analytics_no_data'], +}; diff --git a/packages/shared-ux/page/analytics_no_data/package.json b/packages/shared-ux/page/analytics_no_data/package.json new file mode 100644 index 000000000000000..e9977444fb94e16 --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/package.json @@ -0,0 +1,8 @@ +{ + "name": "@kbn/shared-ux-page-analytics-no-data", + "private": true, + "version": "1.0.0", + "main": "./target_node/index.js", + "browser": "./target_web/index.js", + "license": "SSPL-1.0 OR Elastic License 2.0" +} diff --git a/packages/shared-ux/page/analytics_no_data/src/__snapshots__/analytics_no_data_page.component.test.tsx.snap b/packages/shared-ux/page/analytics_no_data/src/__snapshots__/analytics_no_data_page.component.test.tsx.snap new file mode 100644 index 000000000000000..be6fd3c45744e29 --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/src/__snapshots__/analytics_no_data_page.component.test.tsx.snap @@ -0,0 +1,157 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AnalyticsNoDataPageComponent renders correctly 1`] = ` + + + + } + > + +
+ + + +
+
+
+
+
+
+`; diff --git a/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.component.test.tsx b/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.component.test.tsx new file mode 100644 index 000000000000000..0f187101979917e --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.component.test.tsx @@ -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 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 React from 'react'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { KibanaNoDataPage } from '@kbn/shared-ux-components'; +import { AnalyticsNoDataPage } from './analytics_no_data_page.component'; + +describe('AnalyticsNoDataPageComponent', () => { + const onDataViewCreated = jest.fn(); + + it('renders correctly', () => { + const component = mountWithIntl( + + ); + expect(component).toMatchSnapshot(); + + expect(component.find(KibanaNoDataPage).length).toBe(1); + + const noDataConfig = component.find(KibanaNoDataPage).props().noDataConfig; + expect(noDataConfig.solution).toEqual('Analytics'); + expect(noDataConfig.pageTitle).toEqual('Welcome to Analytics!'); + expect(noDataConfig.logo).toEqual('logoKibana'); + expect(noDataConfig.docsLink).toEqual('http://www.test.com'); + expect(noDataConfig.action.elasticAgent).not.toBeNull(); + }); +}); diff --git a/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.component.tsx b/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.component.tsx new file mode 100644 index 000000000000000..31051328641f4fa --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.component.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 React from 'react'; +import { i18n } from '@kbn/i18n'; +import { KibanaNoDataPage } from '@kbn/shared-ux-components'; + +/** + * Props for the pure component. + */ +export interface Props { + kibanaGuideDocLink: string; + onDataViewCreated: (dataView: unknown) => void; +} + +const solution = i18n.translate('sharedUXPackages.noDataConfig.analytics', { + defaultMessage: 'Analytics', +}); + +const pageTitle = i18n.translate('sharedUXPackages.noDataConfig.analyticsPageTitle', { + defaultMessage: 'Welcome to Analytics!', +}); + +const addIntegrationsTitle = i18n.translate('sharedUXPackages.noDataConfig.addIntegrationsTitle', { + defaultMessage: 'Add integrations', +}); + +const addIntegrationsDescription = i18n.translate( + 'sharedUXPackages.noDataConfig.addIntegrationsDescription', + { + defaultMessage: 'Use Elastic Agent to collect data and build out Analytics solutions.', + } +); + +/** + * A pure component of an entire page that can be displayed when Kibana "has no data", specifically for Analytics. + */ +export const AnalyticsNoDataPage = ({ kibanaGuideDocLink, onDataViewCreated }: Props) => { + const noDataConfig = { + solution, + pageTitle, + logo: 'logoKibana', + action: { + elasticAgent: { + title: addIntegrationsTitle, + description: addIntegrationsDescription, + 'data-test-subj': 'kbnOverviewAddIntegrations', + }, + }, + docsLink: kibanaGuideDocLink, + }; + + return ; +}; diff --git a/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.stories.tsx b/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.stories.tsx new file mode 100644 index 000000000000000..8471cdf9546d2b5 --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.stories.tsx @@ -0,0 +1,61 @@ +/* + * 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 React from 'react'; +import { action } from '@storybook/addon-actions'; +import { servicesFactory, DataServiceFactoryConfig } from '@kbn/shared-ux-storybook'; + +import { AnalyticsNoDataPage as Component } from './analytics_no_data_page'; +import { AnalyticsNoDataPageProvider, Services } from './services'; +import mdx from '../README.mdx'; + +export default { + title: 'Analytics No Data Page', + description: 'An Analytics-specific version of KibanaNoDataPage.', + parameters: { + docs: { + page: mdx, + }, + }, +}; + +type Params = Pick; + +export const AnalyticsNoDataPage = (params: Params) => { + // Workaround to leverage the services package. + const { application, data, docLinks, editors, http, permissions, platform } = + servicesFactory(params); + + const services: Services = { + ...application, + ...data, + ...docLinks, + ...editors, + ...http, + ...permissions, + ...platform, + kibanaGuideDocLink: 'Kibana guide', + }; + + return ( + + + + ); +}; + +AnalyticsNoDataPage.argTypes = { + hasESData: { + control: 'boolean', + defaultValue: false, + }, + hasUserDataView: { + control: 'boolean', + defaultValue: false, + }, +}; diff --git a/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.test.tsx b/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.test.tsx new file mode 100644 index 000000000000000..e091cac70d32bcb --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 React from 'react'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { mockServicesFactory } from '@kbn/shared-ux-services'; + +import { Services, AnalyticsNoDataPageProvider } from './services'; +import { AnalyticsNoDataPage as Component } from './analytics_no_data_page.component'; +import { AnalyticsNoDataPage } from './analytics_no_data_page'; + +describe('AnalyticsNoDataPage', () => { + const onDataViewCreated = jest.fn(); + + // Workaround to leverage the services package. + const { application, data, docLinks, editors, http, permissions, platform } = + mockServicesFactory(); + + const services: Services = { + ...application, + ...data, + ...docLinks, + ...editors, + ...http, + ...permissions, + ...platform, + kibanaGuideDocLink: 'Kibana guide', + }; + + afterAll(() => { + jest.resetAllMocks(); + }); + + it('renders correctly', async () => { + const component = mountWithIntl( + + + + ); + + expect(component.find(Component).length).toBe(1); + expect(component.find(Component).props().kibanaGuideDocLink).toBe(services.kibanaGuideDocLink); + expect(component.find(Component).props().onDataViewCreated).toBe(onDataViewCreated); + }); +}); diff --git a/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.tsx b/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.tsx new file mode 100644 index 000000000000000..141f607a6257e66 --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/src/analytics_no_data_page.tsx @@ -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 React from 'react'; + +import { LegacyServicesProvider, getLegacyServices } from './legacy_services'; +import { useServices } from './services'; +import { AnalyticsNoDataPage as Component } from './analytics_no_data_page.component'; + +/** + * Props for the `AnalyticsNoDataPage` component. + */ +export interface AnalyticsNoDataPageProps { + onDataViewCreated: (dataView: unknown) => void; +} + +/** + * An entire page that can be displayed when Kibana "has no data", specifically for Analytics. Uses + * services from a provider to provide props to a pure component. + */ +export const AnalyticsNoDataPage = ({ onDataViewCreated }: AnalyticsNoDataPageProps) => { + const services = useServices(); + const { kibanaGuideDocLink } = services; + + return ( + + + + ); +}; diff --git a/packages/shared-ux/page/analytics_no_data/src/index.ts b/packages/shared-ux/page/analytics_no_data/src/index.ts new file mode 100644 index 000000000000000..7b87084f745ef3f --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/src/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 React from 'react'; +import { withSuspense } from '@kbn/shared-ux-utility'; + +export { AnalyticsNoDataPageProvider, AnalyticsNoDataPageKibanaProvider } from './services'; + +/** + * Lazy-loaded connected component. Must be wrapped in `React.Suspense`. + */ +export const LazyAnalyticsNoDataPage = React.lazy(() => + import('./analytics_no_data_page').then(({ AnalyticsNoDataPage }) => ({ + default: AnalyticsNoDataPage, + })) +); + +/** + * An entire page that can be displayed when Kibana "has no data", specifically for Analytics. + * Requires a Provider for relevant services. + */ +export const AnalyticsNoDataPage = withSuspense(LazyAnalyticsNoDataPage); diff --git a/packages/shared-ux/page/analytics_no_data/src/legacy_services.tsx b/packages/shared-ux/page/analytics_no_data/src/legacy_services.tsx new file mode 100644 index 000000000000000..3d690e56e0d23d2 --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/src/legacy_services.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { SharedUxServicesProvider as LegacyServicesProvider } from '@kbn/shared-ux-services'; +export type { SharedUxServices as LegacyServices } from '@kbn/shared-ux-services'; + +import { SharedUxServices as LegacyServices } from '@kbn/shared-ux-services'; +import { Services } from './services'; + +/** + * This list is temporary, a stop-gap as we migrate to a package-based architecture, where + * services are not collected in a single package. In order to make the transition, this + * interface is intentionally "flat". + * + * Expect this list to dwindle to zero as `@kbn/shared-ux-components` are migrated to their + * own packages, (and `@kbn/shared-ux-services` is removed). + */ +export const getLegacyServices = (services: Services): LegacyServices => ({ + application: { + currentAppId$: services.currentAppId$, + navigateToUrl: services.navigateToUrl, + }, + data: { + hasESData: services.hasESData, + hasDataView: services.hasDataView, + hasUserDataView: services.hasUserDataView, + }, + docLinks: { + dataViewsDocLink: services.dataViewsDocLink, + }, + editors: { + openDataViewEditor: services.openDataViewEditor, + }, + http: { + addBasePath: services.addBasePath, + }, + permissions: { + canAccessFleet: services.canAccessFleet, + canCreateNewDataView: services.canCreateNewDataView, + }, + platform: { + setIsFullscreen: services.setIsFullscreen, + }, +}); diff --git a/packages/shared-ux/page/analytics_no_data/src/services.tsx b/packages/shared-ux/page/analytics_no_data/src/services.tsx new file mode 100644 index 000000000000000..7868014749997a4 --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/src/services.tsx @@ -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 React, { FC, useContext } from 'react'; +import { Observable } from 'rxjs'; + +/** + * TODO: `DataView` is a class exported by `src/plugins/data_views/public`. Since this service + * is contained in this package-- and packages can only depend on other packages and never on + * plugins-- we have to set this to `unknown`. If and when `DataView` is exported from a + * stateless package, we can remove this. + * + * @see: https://github.com/elastic/kibana/issues/127695 + */ +type DataView = unknown; + +/** + * A subset of the `DataViewEditorOptions` interface relevant to this component. + * + * @see: src/plugins/data_view_editor/public/types.ts + */ +interface DataViewEditorOptions { + /** Handler to be invoked when the Data View Editor completes a save operation. */ + onSave: (dataView: DataView) => void; + /** If set to false, will skip empty prompt in data view editor. */ + showEmptyPrompt?: boolean; +} + +/** + * A list of Services that are consumed by this component. + * + * This list is temporary, a stopgap as we migrate to a package-based architecture, where + * services are not collected in a single package. In order to make the transition, this + * interface is intentionally "flat". + * + * Expect this list to dwindle to zero as `@kbn/shared-ux-components` are migrated to their + * own packages, (and `@kbn/shared-ux-services` is removed). + */ +export interface Services { + addBasePath: (url: string) => string; + canAccessFleet: boolean; + canCreateNewDataView: boolean; + currentAppId$: Observable; + dataViewsDocLink: string; + hasDataView: () => Promise; + hasESData: () => Promise; + hasUserDataView: () => Promise; + kibanaGuideDocLink: string; + navigateToUrl: (url: string) => Promise; + openDataViewEditor: (options: DataViewEditorOptions) => () => void; + setIsFullscreen: (isFullscreen: boolean) => void; +} + +const AnalyticsNoDataPageContext = React.createContext(null); + +/** + * A Context Provider that provides services to the component. + */ +export const AnalyticsNoDataPageProvider: FC = ({ children, ...services }) => { + return ( + + {children} + + ); +}; + +/** + * An interface containing a collection of Kibana plugins and services required to + * render this component and its dependencies. + */ +export interface AnalyticsNoDataPageKibanaDependencies { + coreStart: { + application: { + capabilities: { + navLinks: { + integrations: boolean; + }; + }; + currentAppId$: Observable; + navigateToUrl: (url: string) => Promise; + }; + chrome: { + setIsVisible: (isVisible: boolean) => void; + }; + docLinks: { + links: { + indexPatterns: { + introduction: string; + }; + kibana: { + guide: string; + }; + }; + }; + http: { + basePath: { + prepend: (url: string) => string; + }; + }; + }; + dataViews: { + hasData: { + hasDataView: () => Promise; + hasESData: () => Promise; + hasUserDataView: () => Promise; + }; + }; + dataViewEditor: { + openEditor: (options: DataViewEditorOptions) => () => void; + userPermissions: { + editDataView: () => boolean; + }; + }; +} + +/** + * Kibana-specific Provider that maps dependencies to services. + */ +export const AnalyticsNoDataPageKibanaProvider: FC = ({ + children, + ...dependencies +}) => { + const { coreStart, dataViewEditor, dataViews } = dependencies; + const value: Services = { + addBasePath: coreStart.http.basePath.prepend, + canAccessFleet: coreStart.application.capabilities.navLinks.integrations, + canCreateNewDataView: dataViewEditor.userPermissions.editDataView(), + currentAppId$: coreStart.application.currentAppId$, + dataViewsDocLink: coreStart.docLinks.links.indexPatterns?.introduction, + hasDataView: dataViews.hasData.hasDataView, + hasESData: dataViews.hasData.hasESData, + hasUserDataView: dataViews.hasData.hasUserDataView, + kibanaGuideDocLink: coreStart.docLinks.links.kibana.guide, + navigateToUrl: coreStart.application.navigateToUrl, + openDataViewEditor: dataViewEditor.openEditor, + setIsFullscreen: (isVisible: boolean) => coreStart.chrome.setIsVisible(isVisible), + }; + + return ( + + {children} + + ); +}; + +/** + * React hook for accessing pre-wired services. + */ +export function useServices() { + const context = useContext(AnalyticsNoDataPageContext); + + if (!context) { + throw new Error( + 'AnalyticsNoDataPageContext is missing. Ensure your component or React root is wrapped with AnalyticsNoDataPageContext.' + ); + } + + return context; +} diff --git a/packages/shared-ux/page/analytics_no_data/tsconfig.json b/packages/shared-ux/page/analytics_no_data/tsconfig.json new file mode 100644 index 000000000000000..573ad0732510099 --- /dev/null +++ b/packages/shared-ux/page/analytics_no_data/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../../tsconfig.bazel.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "target_types", + "rootDir": "src", + "stripInternal": false, + "types": [ + "jest", + "node", + "react", + "@kbn/ambient-ui-types", + ] + }, + "include": [ + "src/**/*" + ] +} diff --git a/scripts/extract_performance_testing_dataset.js b/scripts/extract_performance_testing_dataset.js new file mode 100644 index 000000000000000..deb3da481f1e125 --- /dev/null +++ b/scripts/extract_performance_testing_dataset.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +require('../src/setup_node_env'); +require('@kbn/performance-testing-dataset-extractor').runExtractor(); diff --git a/src/core/public/analytics/analytics_service.test.mocks.ts b/src/core/public/analytics/analytics_service.test.mocks.ts new file mode 100644 index 000000000000000..3d98cf439292628 --- /dev/null +++ b/src/core/public/analytics/analytics_service.test.mocks.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 { AnalyticsClient } from '@kbn/analytics-client'; +import { Subject } from 'rxjs'; + +export const analyticsClientMock: jest.Mocked = { + optIn: jest.fn(), + reportEvent: jest.fn(), + registerEventType: jest.fn(), + registerContextProvider: jest.fn(), + removeContextProvider: jest.fn(), + registerShipper: jest.fn(), + telemetryCounter$: new Subject(), + shutdown: jest.fn(), +}; + +jest.doMock('@kbn/analytics-client', () => ({ + createAnalytics: () => analyticsClientMock, +})); diff --git a/src/core/public/analytics/analytics_service.test.ts b/src/core/public/analytics/analytics_service.test.ts new file mode 100644 index 000000000000000..e2298a79ff134b7 --- /dev/null +++ b/src/core/public/analytics/analytics_service.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { firstValueFrom, Observable } from 'rxjs'; +import { analyticsClientMock } from './analytics_service.test.mocks'; +import { coreMock, injectedMetadataServiceMock } from '../mocks'; +import { AnalyticsService } from './analytics_service'; + +describe('AnalyticsService', () => { + let analyticsService: AnalyticsService; + beforeEach(() => { + jest.clearAllMocks(); + analyticsService = new AnalyticsService(coreMock.createCoreContext()); + }); + test('should register some context providers on creation', async () => { + expect(analyticsClientMock.registerContextProvider).toHaveBeenCalledTimes(3); + await expect( + firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[0][0].context$) + ).resolves.toMatchInlineSnapshot(` + Object { + "branch": "branch", + "buildNum": 100, + "buildSha": "buildSha", + "isDev": true, + "isDistributable": false, + "version": "version", + } + `); + await expect( + firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[1][0].context$) + ).resolves.toEqual({ session_id: expect.any(String) }); + await expect( + firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[2][0].context$) + ).resolves.toEqual({ + preferred_language: 'en-US', + preferred_languages: ['en-US', 'en'], + user_agent: expect.any(String), + }); + }); + + test('setup should expose all the register APIs, reportEvent and opt-in', () => { + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + expect(analyticsService.setup({ injectedMetadata })).toStrictEqual({ + registerShipper: expect.any(Function), + registerContextProvider: expect.any(Function), + removeContextProvider: expect.any(Function), + registerEventType: expect.any(Function), + reportEvent: expect.any(Function), + optIn: expect.any(Function), + telemetryCounter$: expect.any(Observable), + }); + }); + + test('setup should register the elasticsearch info context provider (undefined)', async () => { + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + analyticsService.setup({ injectedMetadata }); + await expect( + firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[3][0].context$) + ).resolves.toMatchInlineSnapshot(`undefined`); + }); + + test('setup should register the elasticsearch info context provider (with info)', async () => { + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + injectedMetadata.getElasticsearchInfo.mockReturnValue({ + cluster_name: 'cluster_name', + cluster_uuid: 'cluster_uuid', + cluster_version: 'version', + }); + analyticsService.setup({ injectedMetadata }); + await expect( + firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[3][0].context$) + ).resolves.toMatchInlineSnapshot(` + Object { + "cluster_name": "cluster_name", + "cluster_uuid": "cluster_uuid", + "cluster_version": "version", + } + `); + }); + + test('setup should expose only the APIs report and opt-in', () => { + expect(analyticsService.start()).toStrictEqual({ + reportEvent: expect.any(Function), + optIn: expect.any(Function), + telemetryCounter$: expect.any(Observable), + }); + }); +}); diff --git a/src/core/public/analytics/analytics_service.ts b/src/core/public/analytics/analytics_service.ts index 86b0977faa0c063..f1c00b293808b29 100644 --- a/src/core/public/analytics/analytics_service.ts +++ b/src/core/public/analytics/analytics_service.ts @@ -8,7 +8,11 @@ import type { AnalyticsClient } from '@kbn/analytics-client'; import { createAnalytics } from '@kbn/analytics-client'; +import { of } from 'rxjs'; +import { trackClicks } from './track_clicks'; +import { InjectedMetadataSetup } from '../injected_metadata'; import { CoreContext } from '../core_system'; +import { getSessionId } from './get_session_id'; import { createLogger } from './logger'; /** @@ -27,6 +31,11 @@ export type AnalyticsServiceStart = Pick< 'optIn' | 'reportEvent' | 'telemetryCounter$' >; +/** @internal */ +export interface AnalyticsServiceSetupDeps { + injectedMetadata: InjectedMetadataSetup; +} + export class AnalyticsService { private readonly analyticsClient: AnalyticsClient; @@ -38,9 +47,19 @@ export class AnalyticsService { // For now, we are relying on whether it's a distributable or running from source. sendTo: core.env.packageInfo.dist ? 'production' : 'staging', }); + + this.registerBuildInfoAnalyticsContext(core); + + // We may eventually move the following to the client's package since they are not Kibana-specific + // and can benefit other consumers of the client. + this.registerSessionIdContext(); + this.registerBrowserInfoAnalyticsContext(); + trackClicks(this.analyticsClient, core.env.mode.dev); } - public setup(): AnalyticsServiceSetup { + public setup({ injectedMetadata }: AnalyticsServiceSetupDeps): AnalyticsServiceSetup { + this.registerElasticsearchInfoContext(injectedMetadata); + return { optIn: this.analyticsClient.optIn, registerContextProvider: this.analyticsClient.registerContextProvider, @@ -51,6 +70,7 @@ export class AnalyticsService { telemetryCounter$: this.analyticsClient.telemetryCounter$, }; } + public start(): AnalyticsServiceStart { return { optIn: this.analyticsClient.optIn, @@ -58,7 +78,119 @@ export class AnalyticsService { telemetryCounter$: this.analyticsClient.telemetryCounter$, }; } + public stop() { this.analyticsClient.shutdown(); } + + /** + * Enriches the events with a session_id, so we can correlate them and understand funnels. + * @private + */ + private registerSessionIdContext() { + this.analyticsClient.registerContextProvider({ + name: 'session-id', + context$: of({ session_id: getSessionId() }), + schema: { + session_id: { + type: 'keyword', + _meta: { description: 'Unique session ID for every browser session' }, + }, + }, + }); + } + + /** + * Enriches the event with the build information. + * @param core The core context. + * @private + */ + private registerBuildInfoAnalyticsContext(core: CoreContext) { + this.analyticsClient.registerContextProvider({ + name: 'build info', + context$: of({ + isDev: core.env.mode.dev, + isDistributable: core.env.packageInfo.dist, + version: core.env.packageInfo.version, + branch: core.env.packageInfo.branch, + buildNum: core.env.packageInfo.buildNum, + buildSha: core.env.packageInfo.buildSha, + }), + schema: { + isDev: { + type: 'boolean', + _meta: { description: 'Is it running in development mode?' }, + }, + isDistributable: { + type: 'boolean', + _meta: { description: 'Is it running from a distributable?' }, + }, + version: { type: 'keyword', _meta: { description: 'Version of the Kibana instance.' } }, + branch: { + type: 'keyword', + _meta: { description: 'Branch of source running Kibana from.' }, + }, + buildNum: { type: 'long', _meta: { description: 'Build number of the Kibana instance.' } }, + buildSha: { type: 'keyword', _meta: { description: 'Build SHA of the Kibana instance.' } }, + }, + }); + } + + /** + * Enriches events with the current Browser's information + * @private + */ + private registerBrowserInfoAnalyticsContext() { + this.analyticsClient.registerContextProvider({ + name: 'browser info', + context$: of({ + user_agent: navigator.userAgent, + preferred_language: navigator.language, + preferred_languages: navigator.languages, + }), + schema: { + user_agent: { + type: 'keyword', + _meta: { description: 'User agent of the browser.' }, + }, + preferred_language: { + type: 'keyword', + _meta: { description: 'Preferred language of the browser.' }, + }, + preferred_languages: { + type: 'array', + items: { + type: 'keyword', + _meta: { description: 'List of the preferred languages of the browser.' }, + }, + }, + }, + }); + } + + /** + * Enriches the events with the Elasticsearch info (cluster name, uuid and version). + * @param injectedMetadata The injected metadata service. + * @private + */ + private registerElasticsearchInfoContext(injectedMetadata: InjectedMetadataSetup) { + this.analyticsClient.registerContextProvider({ + name: 'elasticsearch info', + context$: of(injectedMetadata.getElasticsearchInfo()), + schema: { + cluster_name: { + type: 'keyword', + _meta: { description: 'The Cluster Name', optional: true }, + }, + cluster_uuid: { + type: 'keyword', + _meta: { description: 'The Cluster UUID', optional: true }, + }, + cluster_version: { + type: 'keyword', + _meta: { description: 'The Cluster version', optional: true }, + }, + }, + }); + } } diff --git a/src/core/public/analytics/get_session_id.test.ts b/src/core/public/analytics/get_session_id.test.ts new file mode 100644 index 000000000000000..85ac515e29f68e7 --- /dev/null +++ b/src/core/public/analytics/get_session_id.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { getSessionId } from './get_session_id'; + +describe('getSessionId', () => { + test('should return a session id', () => { + const sessionId = getSessionId(); + expect(sessionId).toStrictEqual(expect.any(String)); + }); + + test('calling it twice should return the same value', () => { + const sessionId1 = getSessionId(); + const sessionId2 = getSessionId(); + expect(sessionId2).toStrictEqual(sessionId1); + }); +}); diff --git a/src/core/public/analytics/get_session_id.ts b/src/core/public/analytics/get_session_id.ts new file mode 100644 index 000000000000000..62bb3a4a1c33683 --- /dev/null +++ b/src/core/public/analytics/get_session_id.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 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 { v4 } from 'uuid'; + +/** + * Returns a session ID for the current user. + * We are storing it to the sessionStorage. This means it remains the same through refreshes, + * but it is not persisted when closing the browser/tab or manually navigating to another URL. + */ +export function getSessionId(): string { + const sessionId = sessionStorage.getItem('sessionId') ?? v4(); + sessionStorage.setItem('sessionId', sessionId); + return sessionId; +} diff --git a/src/core/public/analytics/logger.test.ts b/src/core/public/analytics/logger.test.ts new file mode 100644 index 000000000000000..2fbe17e3f7d2200 --- /dev/null +++ b/src/core/public/analytics/logger.test.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { LogRecord } from '@kbn/logging'; +import { createLogger } from './logger'; + +describe('createLogger', () => { + // Calling `.mockImplementation` on all of them to avoid jest logging the console usage + const logErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const logWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const logInfoSpy = jest.spyOn(console, 'info').mockImplementation(); + const logDebugSpy = jest.spyOn(console, 'debug').mockImplementation(); + const logTraceSpy = jest.spyOn(console, 'trace').mockImplementation(); + const logLogSpy = jest.spyOn(console, 'log').mockImplementation(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should create a logger', () => { + const logger = createLogger(false); + expect(logger).toStrictEqual( + expect.objectContaining({ + fatal: expect.any(Function), + error: expect.any(Function), + warn: expect.any(Function), + info: expect.any(Function), + debug: expect.any(Function), + trace: expect.any(Function), + log: expect.any(Function), + get: expect.any(Function), + }) + ); + }); + + test('when isDev === false, it should not log anything', () => { + const logger = createLogger(false); + logger.fatal('fatal'); + expect(logErrorSpy).not.toHaveBeenCalled(); + logger.error('error'); + expect(logErrorSpy).not.toHaveBeenCalled(); + logger.warn('warn'); + expect(logWarnSpy).not.toHaveBeenCalled(); + logger.info('info'); + expect(logInfoSpy).not.toHaveBeenCalled(); + logger.debug('debug'); + expect(logDebugSpy).not.toHaveBeenCalled(); + logger.trace('trace'); + expect(logTraceSpy).not.toHaveBeenCalled(); + logger.log({} as LogRecord); + expect(logLogSpy).not.toHaveBeenCalled(); + logger.get().warn('warn'); + expect(logWarnSpy).not.toHaveBeenCalled(); + }); + + test('when isDev === true, it should log everything', () => { + const logger = createLogger(true); + logger.fatal('fatal'); + expect(logErrorSpy).toHaveBeenCalledTimes(1); + logger.error('error'); + expect(logErrorSpy).toHaveBeenCalledTimes(2); // fatal + error + logger.warn('warn'); + expect(logWarnSpy).toHaveBeenCalledTimes(1); + logger.info('info'); + expect(logInfoSpy).toHaveBeenCalledTimes(1); + logger.debug('debug'); + expect(logDebugSpy).toHaveBeenCalledTimes(1); + logger.trace('trace'); + expect(logTraceSpy).toHaveBeenCalledTimes(1); + logger.log({} as LogRecord); + expect(logLogSpy).toHaveBeenCalledTimes(1); + logger.get().warn('warn'); + expect(logWarnSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/core/public/analytics/track_clicks.test.ts b/src/core/public/analytics/track_clicks.test.ts new file mode 100644 index 000000000000000..db1b8fc215cf89e --- /dev/null +++ b/src/core/public/analytics/track_clicks.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { firstValueFrom, ReplaySubject } from 'rxjs'; +import { analyticsClientMock } from './analytics_service.test.mocks'; +import { trackClicks } from './track_clicks'; +import { take } from 'rxjs/operators'; + +describe('trackClicks', () => { + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('registers the analytics event type and a listener to the "click" events', () => { + trackClicks(analyticsClientMock, true); + + expect(analyticsClientMock.registerEventType).toHaveBeenCalledTimes(1); + expect(analyticsClientMock.registerEventType).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'click', + }) + ); + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function), undefined); + }); + + test('reports an analytics event when a click event occurs', async () => { + // Gather an actual "click" event + const event$ = new ReplaySubject(1); + const parent = document.createElement('div'); + parent.setAttribute('data-test-subj', 'test-click-target-parent'); + const element = document.createElement('button'); + parent.appendChild(element); + element.setAttribute('data-test-subj', 'test-click-target'); + element.innerText = 'test'; // Only to validate that it is not included in the event. + element.value = 'test'; // Only to validate that it is not included in the event. + element.addEventListener('click', (e) => event$.next(e)); + element.click(); + // Using an observable because the event might not be immediately available + const event = await firstValueFrom(event$.pipe(take(1))); + event$.complete(); // No longer needed + + trackClicks(analyticsClientMock, true); + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + + (addEventListenerSpy.mock.calls[0][1] as EventListener)(event); + expect(analyticsClientMock.reportEvent).toHaveBeenCalledTimes(1); + expect(analyticsClientMock.reportEvent).toHaveBeenCalledWith('click', { + target: [ + 'DIV', + 'data-test-subj=test-click-target-parent', + 'BUTTON', + 'data-test-subj=test-click-target', + ], + }); + }); + + test('handles any processing errors logging them in dev mode', async () => { + trackClicks(analyticsClientMock, true); + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + + // A basic MouseEvent does not have a target and will fail the logic, making it go to the catch branch as intended. + (addEventListenerSpy.mock.calls[0][1] as EventListener)(new MouseEvent('click')); + expect(analyticsClientMock.reportEvent).toHaveBeenCalledTimes(0); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "Failed to report the click event", + Object { + "error": [TypeError: Cannot read properties of null (reading 'parentElement')], + "event": MouseEvent { + "isTrusted": false, + }, + }, + ] + `); + }); + + test('swallows any processing errors when not in dev mode', async () => { + trackClicks(analyticsClientMock, false); + expect(addEventListenerSpy).toHaveBeenCalledTimes(1); + + // A basic MouseEvent does not have a target and will fail the logic, making it go to the catch branch as intended. + (addEventListenerSpy.mock.calls[0][1] as EventListener)(new MouseEvent('click')); + expect(analyticsClientMock.reportEvent).toHaveBeenCalledTimes(0); + expect(consoleErrorSpy).toHaveBeenCalledTimes(0); + }); +}); diff --git a/src/core/public/analytics/track_clicks.ts b/src/core/public/analytics/track_clicks.ts new file mode 100644 index 000000000000000..f2ba7c25de7681a --- /dev/null +++ b/src/core/public/analytics/track_clicks.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { fromEvent } from 'rxjs'; +import type { AnalyticsClient } from '@kbn/analytics-client'; + +/** HTML attributes that should be skipped from reporting because they might contain user data */ +const POTENTIAL_PII_HTML_ATTRIBUTES = ['value']; + +/** + * Registers the event type "click" in the analytics client. + * Then it listens to all the "click" events in the UI and reports them with the `target` property being a + * full list of the element's and its parents' attributes. This allows + * @param analytics + */ +export function trackClicks(analytics: AnalyticsClient, isDevMode: boolean) { + analytics.registerEventType<{ target: string[] }>({ + eventType: 'click', + schema: { + target: { + type: 'array', + items: { + type: 'keyword', + _meta: { + description: + 'The attributes of the clicked element and all its parents in the form `{attr.name}={attr.value}`. It allows finding the clicked elements by looking up its attributes like "data-test-subj=my-button".', + }, + }, + }, + }, + }); + + // window or document? + // I tested it on multiple browsers and it seems to work the same. + // My assumption is that window captures other documents like iframes as well? + return fromEvent(window, 'click').subscribe((event) => { + try { + const target = event.target as HTMLElement; + analytics.reportEvent('click', { target: getTargetDefinition(target) }); + } catch (error) { + if (isDevMode) { + // Defensively log the error in dev mode to catch any potential bugs. + // eslint-disable-next-line no-console + console.error(`Failed to report the click event`, { event, error }); + } + } + }); +} + +/** + * Returns a list of strings consisting on the tag name and all the attributes of the element. + * Additionally, it recursively walks up the DOM tree to find all the parents' definitions and prepends them to the list. + * + * @example + * From + * ```html + *
+ *
+ *
+ * ``` + * it returns ['DIV', 'data-test-subj=my-parent', 'DIV', 'data-test-subj=my-button'] + * @param target The child node to start from. + */ +function getTargetDefinition(target: HTMLElement): string[] { + return [ + ...(target.parentElement ? getTargetDefinition(target.parentElement) : []), + target.tagName, + ...[...target.attributes] + .filter((attr) => !POTENTIAL_PII_HTML_ATTRIBUTES.includes(attr.name)) + .map((attr) => `${attr.name}=${attr.value}`), + ]; +} diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index df638e4b66bbed2..4e36b691158e68d 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -1,2008 +1,699 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`CollapsibleNav renders links grouped by category 1`] = ` -} - closeNav={[Function]} - customNavLink$={ - BehaviorSubject { - "_value": Object { - "baseUrl": "/", - "category": undefined, - "data-test-subj": "Custom link", - "href": "Custom link", - "id": "Custom link", - "isActive": true, - "title": "Custom link", - "url": "/", - }, - "closed": false, - "currentObservers": null, - "hasError": false, - "isStopped": false, - "observers": Array [ - SafeSubscriber { - "_finalizers": Array [ - Subscription { - "_finalizers": null, - "_parentage": [Circular], - "closed": false, - "initialTeardown": [Function], - }, - ], - "_parentage": null, - "closed": false, - "destination": ConsumerObserver { - "partialObserver": Object { - "complete": undefined, - "error": undefined, - "next": [Function], - }, - }, - "initialTeardown": undefined, - "isStopped": false, - }, - ], - "thrownError": null, - } - } - homeHref="/" - id="collapsibe-nav" - isLocked={false} - isNavOpen={true} - navLinks$={ - BehaviorSubject { - "_value": Array [ - Object { - "baseUrl": "/", - "category": Object { - "euiIconType": "logoKibana", - "id": "kibana", - "label": "Analytics", - "order": 1000, - }, - "data-test-subj": "discover", - "href": "discover", - "id": "discover", - "isActive": true, - "title": "discover", - "url": "/", - }, - Object { - "baseUrl": "/", - "category": Object { - "euiIconType": "logoSecurity", - "id": "securitySolution", - "label": "Security", - "order": 4000, - }, - "data-test-subj": "siem", - "href": "siem", - "id": "siem", - "isActive": true, - "title": "siem", - "url": "/", - }, - Object { - "baseUrl": "/", - "category": Object { - "euiIconType": "logoObservability", - "id": "observability", - "label": "Observability", - "order": 3000, - }, - "data-test-subj": "metrics", - "href": "metrics", - "id": "metrics", - "isActive": true, - "title": "metrics", - "url": "/", - }, - Object { - "baseUrl": "/", - "category": Object { - "euiIconType": "managementApp", - "id": "management", - "label": "Management", - "order": 5000, - }, - "data-test-subj": "monitoring", - "href": "monitoring", - "id": "monitoring", - "isActive": true, - "title": "monitoring", - "url": "/", - }, - Object { - "baseUrl": "/", - "category": Object { - "euiIconType": "logoKibana", - "id": "kibana", - "label": "Analytics", - "order": 1000, - }, - "data-test-subj": "visualize", - "href": "visualize", - "id": "visualize", - "isActive": true, - "title": "visualize", - "url": "/", - }, - Object { - "baseUrl": "/", - "category": Object { - "euiIconType": "logoKibana", - "id": "kibana", - "label": "Analytics", - "order": 1000, - }, - "data-test-subj": "dashboard", - "href": "dashboard", - "id": "dashboard", - "isActive": true, - "title": "dashboard", - "url": "/", - }, - Object { - "baseUrl": "/", - "category": undefined, - "data-test-subj": "canvas", - "href": "canvas", - "id": "canvas", - "isActive": true, - "title": "canvas", - "url": "/", - }, - Object { - "baseUrl": "/", - "category": Object { - "euiIconType": "logoObservability", - "id": "observability", - "label": "Observability", - "order": 3000, - }, - "data-test-subj": "logs", - "href": "logs", - "id": "logs", - "isActive": true, - "title": "logs", - "url": "/", - }, - ], - "closed": false, - "currentObservers": null, - "hasError": false, - "isStopped": false, - "observers": Array [ - SafeSubscriber { - "_finalizers": Array [ - Subscription { - "_finalizers": null, - "_parentage": [Circular], - "closed": false, - "initialTeardown": [Function], - }, - ], - "_parentage": null, - "closed": false, - "destination": ConsumerObserver { - "partialObserver": Object { - "complete": undefined, - "error": undefined, - "next": [Function], - }, - }, - "initialTeardown": undefined, - "isStopped": false, - }, - ], - "thrownError": null, - } - } - navigateToApp={[Function]} - navigateToUrl={[Function]} - onIsLockedUpdate={[Function]} - recentlyAccessed$={ - BehaviorSubject { - "_value": Array [ - Object { - "id": "recent 1", - "label": "recent 1", - "link": "recent 1", - }, - Object { - "id": "recent 2", - "label": "recent 2", - "link": "recent 2", - }, - ], - "closed": false, - "currentObservers": null, - "hasError": false, - "isStopped": false, - "observers": Array [ - SafeSubscriber { - "_finalizers": Array [ - Subscription { - "_finalizers": null, - "_parentage": [Circular], - "closed": false, - "initialTeardown": [Function], - }, - ], - "_parentage": null, - "closed": false, - "destination": ConsumerObserver { - "partialObserver": Object { - "complete": undefined, - "error": undefined, - "next": [Function], - }, - }, - "initialTeardown": undefined, - "isStopped": false, - }, - ], - "thrownError": null, - } - } - storage={ - StubBrowserStorage { - "keys": Array [], - "size": 0, - "sizeLimit": 5000000, - "values": Array [], - } - } - url="/" -> - } +Array [ +
+ +
+
+
+
+ +
+
+
+ + + +
+
+
+
+ - +
+
- - -
-
- -
-
-
- - - -
-
-
-
+ Analytics + +
-
- - - -
-
- -
+ + +
+
+
+
- - - - - - -

- Analytics -

-
-
- - } - buttonElement="button" - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-kibana" - element="div" - id="generated-id" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
-
-
- - - - -
-
- -
-
-
- - - -
-
-
-
-
-
- - - - + +
  • - - - - - -

    - Observability -

    -
    -
    - - } - buttonElement="button" - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-observability" - element="div" - id="generated-id" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" - > -
    -
    + +
  • +
  • - - - - -
  • -
    + + +
    +
    +
    +
    + +
    +
    + + +
    +
    +
    +
    - - - - - - -

    - Security -

    -
    -
    - - } - buttonElement="button" - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-securitySolution" - element="div" - id="generated-id" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
    -
    -
    - - + +
  • + - - -
  • -
    + + + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    - - - - - - -

    - Management -

    -
    -
    - - } - buttonElement="button" - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-management" - element="div" - id="generated-id" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
    -
    -
    - - - - -
    -
    + + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    -
    - - - -
    + monitoring + + + +
    - +
    - - - - - +
    +
    +
    +
    + +
    +
    +
    + , +] `; exports[`CollapsibleNav renders the default nav 1`] = ` diff --git a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx index 0102343ca6eb7e3..787fdc031f1a5fd 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx @@ -113,7 +113,7 @@ describe('CollapsibleNav', () => { customNavLink$={new BehaviorSubject(customNavLink)} /> ); - expect(component).toMatchSnapshot(); + expect(component.render()).toMatchSnapshot(); }); it('remembers collapsible section state', () => { diff --git a/src/core/public/core_system.test.mocks.ts b/src/core/public/core_system.test.mocks.ts index 6eddf08cd2ae136..ff24cc88397942b 100644 --- a/src/core/public/core_system.test.mocks.ts +++ b/src/core/public/core_system.test.mocks.ts @@ -21,6 +21,20 @@ import { renderingServiceMock } from './rendering/rendering_service.mock'; import { integrationsServiceMock } from './integrations/integrations_service.mock'; import { coreAppMock } from './core_app/core_app.mock'; import { themeServiceMock } from './theme/theme_service.mock'; +import { analyticsServiceMock } from './analytics/analytics_service.mock'; + +export const analyticsServiceStartMock = analyticsServiceMock.createAnalyticsServiceStart(); +export const MockAnalyticsService = analyticsServiceMock.create(); +MockAnalyticsService.start.mockReturnValue(analyticsServiceStartMock); +export const AnalyticsServiceConstructor = jest.fn().mockReturnValue(MockAnalyticsService); +jest.doMock('./analytics', () => ({ + AnalyticsService: AnalyticsServiceConstructor, +})); + +export const fetchOptionalMemoryInfoMock = jest.fn(); +jest.doMock('./fetch_optional_memory_info', () => ({ + fetchOptionalMemoryInfo: fetchOptionalMemoryInfoMock, +})); export const MockInjectedMetadataService = injectedMetadataServiceMock.create(); export const InjectedMetadataServiceConstructor = jest diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index 553c1668951e826..2a57364c9f93ff8 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -34,6 +34,10 @@ import { MockCoreApp, MockThemeService, ThemeServiceConstructor, + AnalyticsServiceConstructor, + MockAnalyticsService, + analyticsServiceStartMock, + fetchOptionalMemoryInfoMock, } from './core_system.test.mocks'; import { CoreSystem } from './core_system'; @@ -56,6 +60,7 @@ const defaultCoreSystemParams = { }, packageInfo: { dist: false, + version: '1.2.3', }, }, version: 'version', @@ -90,6 +95,7 @@ describe('constructor', () => { expect(IntegrationsServiceConstructor).toHaveBeenCalledTimes(1); expect(CoreAppConstructor).toHaveBeenCalledTimes(1); expect(ThemeServiceConstructor).toHaveBeenCalledTimes(1); + expect(AnalyticsServiceConstructor).toHaveBeenCalledTimes(1); }); it('passes injectedMetadata param to InjectedMetadataService', () => { @@ -146,6 +152,11 @@ describe('#setup()', () => { return core.setup(); } + it('calls analytics#setup()', async () => { + await setupCore(); + expect(MockAnalyticsService.setup).toHaveBeenCalledTimes(1); + }); + it('calls application#setup()', async () => { await setupCore(); expect(MockApplicationService.setup).toHaveBeenCalledTimes(1); @@ -222,6 +233,36 @@ describe('#start()', () => { ); }); + it('reports the event Loaded Kibana', async () => { + await startCore(); + expect(analyticsServiceStartMock.reportEvent).toHaveBeenCalledTimes(1); + expect(analyticsServiceStartMock.reportEvent).toHaveBeenCalledWith('Loaded Kibana', { + kibana_version: '1.2.3', + }); + }); + + it('reports the event Loaded Kibana (with memory)', async () => { + fetchOptionalMemoryInfoMock.mockReturnValue({ + memory_js_heap_size_limit: 3, + memory_js_heap_size_total: 2, + memory_js_heap_size_used: 1, + }); + + await startCore(); + expect(analyticsServiceStartMock.reportEvent).toHaveBeenCalledTimes(1); + expect(analyticsServiceStartMock.reportEvent).toHaveBeenCalledWith('Loaded Kibana', { + kibana_version: '1.2.3', + memory_js_heap_size_limit: 3, + memory_js_heap_size_total: 2, + memory_js_heap_size_used: 1, + }); + }); + + it('calls analytics#start()', async () => { + await startCore(); + expect(MockAnalyticsService.start).toHaveBeenCalledTimes(1); + }); + it('calls application#start()', async () => { await startCore(); expect(MockApplicationService.start).toHaveBeenCalledTimes(1); diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 9334dd579f0f39f..9ea1f16f7f22636 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -32,7 +32,9 @@ import { ThemeService } from './theme'; import { CoreApp } from './core_app'; import type { InternalApplicationSetup, InternalApplicationStart } from './application/types'; import { ExecutionContextService } from './execution_context'; +import type { AnalyticsServiceSetup } from './analytics'; import { AnalyticsService } from './analytics'; +import { fetchOptionalMemoryInfo } from './fetch_optional_memory_info'; interface Params { rootDomElement: HTMLElement; @@ -148,9 +150,10 @@ export class CoreSystem { await this.integrations.setup(); this.docLinks.setup(); - const analytics = this.analytics.setup(); + const analytics = this.analytics.setup({ injectedMetadata }); + this.registerLoadedKibanaEventType(analytics); - const executionContext = this.executionContext.setup(); + const executionContext = this.executionContext.setup({ analytics }); const http = this.http.setup({ injectedMetadata, fatalErrors: this.fatalErrorsSetup, @@ -273,6 +276,11 @@ export class CoreSystem { targetDomElement: coreUiTargetDomElement, }); + analytics.reportEvent('Loaded Kibana', { + kibana_version: this.coreContext.env.packageInfo.version, + ...fetchOptionalMemoryInfo(), + }); + return { application, executionContext, @@ -303,4 +311,28 @@ export class CoreSystem { this.analytics.stop(); this.rootDomElement.textContent = ''; } + + private registerLoadedKibanaEventType(analytics: AnalyticsServiceSetup) { + analytics.registerEventType({ + eventType: 'Loaded Kibana', + schema: { + kibana_version: { + type: 'keyword', + _meta: { description: 'The version of Kibana' }, + }, + memory_js_heap_size_limit: { + type: 'long', + _meta: { description: 'The maximum size of the heap', optional: true }, + }, + memory_js_heap_size_total: { + type: 'long', + _meta: { description: 'The total size of the heap', optional: true }, + }, + memory_js_heap_size_used: { + type: 'long', + _meta: { description: 'The used size of the heap', optional: true }, + }, + }, + }); + } } diff --git a/src/core/public/execution_context/execution_context_service.test.ts b/src/core/public/execution_context/execution_context_service.test.ts index 70e57b8993bb1a4..5c8f8bfae89f8cb 100644 --- a/src/core/public/execution_context/execution_context_service.test.ts +++ b/src/core/public/execution_context/execution_context_service.test.ts @@ -5,23 +5,45 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, firstValueFrom } from 'rxjs'; import { ExecutionContextService, ExecutionContextSetup } from './execution_context_service'; +import type { AnalyticsServiceSetup } from '../analytics'; +import { analyticsServiceMock } from '../analytics/analytics_service.mock'; describe('ExecutionContextService', () => { let execContext: ExecutionContextSetup; let curApp$: BehaviorSubject; let execService: ExecutionContextService; + let analytics: jest.Mocked; beforeEach(() => { + analytics = analyticsServiceMock.createAnalyticsServiceSetup(); execService = new ExecutionContextService(); - execContext = execService.setup(); + execContext = execService.setup({ analytics }); curApp$ = new BehaviorSubject('app1'); execContext = execService.start({ curApp$, }); }); + it('should extend the analytics context', async () => { + expect(analytics.registerContextProvider).toHaveBeenCalledTimes(1); + const context$ = analytics.registerContextProvider.mock.calls[0][0].context$; + execContext.set({ + type: 'ghf', + description: 'first set', + }); + + await expect(firstValueFrom(context$)).resolves.toMatchInlineSnapshot(` + Object { + "applicationId": "app1", + "entityId": undefined, + "page": undefined, + "pageName": "ghf:app1", + } + `); + }); + it('app name updates automatically and clears everything else', () => { execContext.set({ type: 'ghf', diff --git a/src/core/public/execution_context/execution_context_service.ts b/src/core/public/execution_context/execution_context_service.ts index a14d876c9643c87..c8d198b9c84f8df 100644 --- a/src/core/public/execution_context/execution_context_service.ts +++ b/src/core/public/execution_context/execution_context_service.ts @@ -6,8 +6,9 @@ * Side Public License, v 1. */ -import { isEqual, isUndefined, omitBy } from 'lodash'; -import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { compact, isEqual, isUndefined, omitBy } from 'lodash'; +import { BehaviorSubject, Observable, Subscription, map } from 'rxjs'; +import { AnalyticsServiceSetup } from '../analytics'; import { CoreService, KibanaExecutionContext } from '../../types'; // Should be exported from elastic/apm-rum @@ -55,6 +56,10 @@ export interface ExecutionContextSetup { */ export type ExecutionContextStart = ExecutionContextSetup; +export interface SetupDeps { + analytics: AnalyticsServiceSetup; +} + export interface StartDeps { curApp$: Observable; } @@ -68,7 +73,9 @@ export class ExecutionContextService private subscription: Subscription = new Subscription(); private contract?: ExecutionContextSetup; - public setup() { + public setup({ analytics }: SetupDeps) { + this.enrichAnalyticsContext(analytics); + this.contract = { context$: this.context$.asObservable(), clear: () => { @@ -134,4 +141,45 @@ export class ExecutionContextService ...context, }; } + + /** + * Sets the analytics context provider based on the execution context details. + * @param analytics The analytics service + * @private + */ + private enrichAnalyticsContext(analytics: AnalyticsServiceSetup) { + analytics.registerContextProvider({ + name: 'execution_context', + context$: this.context$.pipe( + map(({ type, name, page, id }) => ({ + pageName: `${compact([type, name, page]).join(':')}`, + applicationId: name ?? type ?? 'unknown', + page, + entityId: id, + })) + ), + schema: { + pageName: { + type: 'keyword', + _meta: { description: 'The name of the current page' }, + }, + page: { + type: 'keyword', + _meta: { description: 'The current page', optional: true }, + }, + applicationId: { + type: 'keyword', + _meta: { description: 'The id of the current application' }, + }, + entityId: { + type: 'keyword', + _meta: { + description: + 'The id of the current entity (dashboard, visualization, canvas, lens, etc)', + optional: true, + }, + }, + }, + }); + } } diff --git a/src/core/public/fetch_optional_memory_info.test.ts b/src/core/public/fetch_optional_memory_info.test.ts new file mode 100644 index 000000000000000..f92fad9c14d6343 --- /dev/null +++ b/src/core/public/fetch_optional_memory_info.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { fetchOptionalMemoryInfo } from './fetch_optional_memory_info'; + +describe('fetchOptionalMemoryInfo', () => { + test('should return undefined if no memory info is available', () => { + expect(fetchOptionalMemoryInfo()).toBeUndefined(); + }); + + test('should return the memory info when available', () => { + // @ts-expect-error 2339 + window.performance.memory = { + get jsHeapSizeLimit() { + return 3; + }, + get totalJSHeapSize() { + return 2; + }, + get usedJSHeapSize() { + return 1; + }, + }; + expect(fetchOptionalMemoryInfo()).toEqual({ + memory_js_heap_size_limit: 3, + memory_js_heap_size_total: 2, + memory_js_heap_size_used: 1, + }); + }); +}); diff --git a/src/core/public/fetch_optional_memory_info.ts b/src/core/public/fetch_optional_memory_info.ts new file mode 100644 index 000000000000000..b18f3ca2698da37 --- /dev/null +++ b/src/core/public/fetch_optional_memory_info.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +/** + * `Performance.memory` output. + * https://developer.mozilla.org/en-US/docs/Web/API/Performance/memory + */ +export interface BrowserPerformanceMemoryInfo { + /** + * The maximum size of the heap, in bytes, that is available to the context. + */ + memory_js_heap_size_limit: number; + /** + * The total allocated heap size, in bytes. + */ + memory_js_heap_size_total: number; + /** + * The currently active segment of JS heap, in bytes. + */ + memory_js_heap_size_used: number; +} + +/** + * Get performance information from the browser (non-standard property). + * @remarks Only available in Google Chrome and MS Edge for now. + */ +export function fetchOptionalMemoryInfo(): BrowserPerformanceMemoryInfo | undefined { + // @ts-expect-error 2339 + const memory = window.performance.memory; + if (memory) { + return { + memory_js_heap_size_limit: memory.jsHeapSizeLimit, + memory_js_heap_size_total: memory.totalJSHeapSize, + memory_js_heap_size_used: memory.usedJSHeapSize, + }; + } +} diff --git a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap index a3e3ca7a7c20710..777e7876c14769d 100644 --- a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap +++ b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap @@ -141,6 +141,7 @@ exports[`#start() returns \`Context\` component 1`] = ` "euiImage.openImage": [Function], "euiLink.external.ariaLabel": "External link", "euiLink.newTarget.screenReaderOnlyText": "(opens in a new tab or window)", + "euiLoadingChart.ariaLabel": "Loading", "euiMark.highlightEnd": "highlight end", "euiMark.highlightStart": "highlight start", "euiMarkdownEditorFooter.closeButton": "Close", diff --git a/src/core/public/i18n/i18n_eui_mapping.tsx b/src/core/public/i18n/i18n_eui_mapping.tsx index 5344fddc4fe2ec5..bf14153ef0337e6 100644 --- a/src/core/public/i18n/i18n_eui_mapping.tsx +++ b/src/core/public/i18n/i18n_eui_mapping.tsx @@ -618,6 +618,9 @@ export const getEuiContextMapping = (): EuiTokensObject => { defaultMessage: '(opens in a new tab or window)', } ), + 'euiLoadingChart.ariaLabel': i18n.translate('core.euiLoadingChart.ariaLabel', { + defaultMessage: 'Loading', + }), 'euiMark.highlightStart': i18n.translate('core.euiMark.highlightStart', { defaultMessage: 'highlight start', }), diff --git a/src/core/public/injected_metadata/injected_metadata_service.mock.ts b/src/core/public/injected_metadata/injected_metadata_service.mock.ts index dc8fe63724411d0..83903942df53d72 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.mock.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.mock.ts @@ -16,6 +16,7 @@ const createSetupContractMock = () => { getPublicBaseUrl: jest.fn(), getKibanaVersion: jest.fn(), getKibanaBranch: jest.fn(), + getElasticsearchInfo: jest.fn(), getCspConfig: jest.fn(), getExternalUrlConfig: jest.fn(), getAnonymousStatusPage: jest.fn(), diff --git a/src/core/public/injected_metadata/injected_metadata_service.test.ts b/src/core/public/injected_metadata/injected_metadata_service.test.ts index 3237401b38fa805..ba0e2470d7f2645 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.test.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.test.ts @@ -9,6 +9,36 @@ import { DiscoveredPlugin } from '../../server'; import { InjectedMetadataService } from './injected_metadata_service'; +describe('setup.getElasticsearchInfo()', () => { + it('returns elasticsearch info from injectedMetadata', () => { + const setup = new InjectedMetadataService({ + injectedMetadata: { + clusterInfo: { + cluster_uuid: 'foo', + cluster_name: 'cluster_name', + cluster_version: 'version', + }, + }, + } as any).setup(); + + expect(setup.getElasticsearchInfo()).toEqual({ + cluster_uuid: 'foo', + cluster_name: 'cluster_name', + cluster_version: 'version', + }); + }); + + it('returns elasticsearch info as undefined if not present in the injectedMetadata', () => { + const setup = new InjectedMetadataService({ + injectedMetadata: { + clusterInfo: {}, + }, + } as any).setup(); + + expect(setup.getElasticsearchInfo()).toEqual({}); + }); +}); + describe('setup.getKibanaBuildNumber()', () => { it('returns buildNumber from injectedMetadata', () => { const setup = new InjectedMetadataService({ diff --git a/src/core/public/injected_metadata/injected_metadata_service.ts b/src/core/public/injected_metadata/injected_metadata_service.ts index 07f56b889fc790a..2e19da5c2cffe5a 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.ts @@ -27,6 +27,12 @@ export interface InjectedPluginMetadata { }; } +export interface InjectedMetadataClusterInfo { + cluster_uuid?: string; + cluster_name?: string; + cluster_version?: string; +} + /** @internal */ export interface InjectedMetadataParams { injectedMetadata: { @@ -36,6 +42,7 @@ export interface InjectedMetadataParams { basePath: string; serverBasePath: string; publicBaseUrl: string; + clusterInfo: InjectedMetadataClusterInfo; category?: AppCategory; csp: { warnLegacyBrowsers: boolean; @@ -143,6 +150,10 @@ export class InjectedMetadataService { getTheme: () => { return this.state.theme; }, + + getElasticsearchInfo: () => { + return this.state.clusterInfo; + }, }; } } @@ -169,6 +180,7 @@ export interface InjectedMetadataSetup { darkMode: boolean; version: ThemeVersion; }; + getElasticsearchInfo: () => InjectedMetadataClusterInfo; /** * An array of frontend plugins in topological order. */ diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 3e431f07bd1cf77..732ba71fcd2afa4 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1590,6 +1590,6 @@ export interface UserProvidedValues { // Warnings were encountered during analysis: // -// src/core/public/core_system.ts:192:21 - (ae-forgotten-export) The symbol "InternalApplicationStart" needs to be exported by the entry point index.d.ts +// src/core/public/core_system.ts:195:21 - (ae-forgotten-export) The symbol "InternalApplicationStart" needs to be exported by the entry point index.d.ts ``` diff --git a/src/core/server/analytics/analytics_service.ts b/src/core/server/analytics/analytics_service.ts index 3afc997fd52ea4d..24389dfa7e9386a 100644 --- a/src/core/server/analytics/analytics_service.ts +++ b/src/core/server/analytics/analytics_service.ts @@ -8,6 +8,7 @@ import type { AnalyticsClient } from '@kbn/analytics-client'; import { createAnalytics } from '@kbn/analytics-client'; +import { of } from 'rxjs'; import type { CoreContext } from '../core_context'; /** @@ -43,6 +44,8 @@ export class AnalyticsService { // For now, we are relying on whether it's a distributable or running from source. sendTo: core.env.packageInfo.dist ? 'production' : 'staging', }); + + this.registerBuildInfoAnalyticsContext(core); } public preboot(): AnalyticsServicePreboot { @@ -74,7 +77,44 @@ export class AnalyticsService { telemetryCounter$: this.analyticsClient.telemetryCounter$, }; } + public stop() { this.analyticsClient.shutdown(); } + + /** + * Enriches the event with the build information. + * @param core The core context. + * @private + */ + private registerBuildInfoAnalyticsContext(core: CoreContext) { + this.analyticsClient.registerContextProvider({ + name: 'build info', + context$: of({ + isDev: core.env.mode.dev, + isDistributable: core.env.packageInfo.dist, + version: core.env.packageInfo.version, + branch: core.env.packageInfo.branch, + buildNum: core.env.packageInfo.buildNum, + buildSha: core.env.packageInfo.buildSha, + }), + schema: { + isDev: { + type: 'boolean', + _meta: { description: 'Is it running in development mode?' }, + }, + isDistributable: { + type: 'boolean', + _meta: { description: 'Is it running from a distributable?' }, + }, + version: { type: 'keyword', _meta: { description: 'Version of the Kibana instance.' } }, + branch: { + type: 'keyword', + _meta: { description: 'Branch of source running Kibana from.' }, + }, + buildNum: { type: 'long', _meta: { description: 'Build number of the Kibana instance.' } }, + buildSha: { type: 'keyword', _meta: { description: 'Build SHA of the Kibana instance.' } }, + }, + }); + } } diff --git a/src/core/server/elasticsearch/elasticsearch_service.mock.ts b/src/core/server/elasticsearch/elasticsearch_service.mock.ts index 3ef44e2690a95da..02a846a5b8011c0 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.mock.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.mock.ts @@ -25,6 +25,7 @@ import { } from './types'; import { NodesVersionCompatibility } from './version_check/ensure_es_version'; import { ServiceStatus, ServiceStatusLevels } from '../status'; +import type { ClusterInfo } from './get_cluster_info'; type MockedElasticSearchServicePreboot = jest.Mocked; @@ -89,6 +90,11 @@ const createInternalSetupContractMock = () => { warningNodes: [], kibanaVersion: '8.0.0', }), + clusterInfo$: new BehaviorSubject({ + cluster_uuid: 'cluster-uuid', + cluster_name: 'cluster-name', + cluster_version: '8.0.0', + }), status$: new BehaviorSubject>({ level: ServiceStatusLevels.available, summary: 'Elasticsearch is available', diff --git a/src/core/server/elasticsearch/elasticsearch_service.test.ts b/src/core/server/elasticsearch/elasticsearch_service.test.ts index def2c400258b544..875995cd7cd96b0 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.test.ts @@ -34,6 +34,7 @@ import { elasticsearchClientMock } from './client/mocks'; import { duration } from 'moment'; import { isValidConnection as isValidConnectionMock } from './is_valid_connection'; import { pollEsNodesVersion as pollEsNodesVersionMocked } from './version_check/ensure_es_version'; +import { analyticsServiceMock } from '../analytics/analytics_service.mock'; const { pollEsNodesVersion: pollEsNodesVersionActual } = jest.requireActual( './version_check/ensure_es_version' @@ -53,6 +54,7 @@ let setupDeps: SetupDeps; beforeEach(() => { setupDeps = { + analytics: analyticsServiceMock.createAnalyticsServiceSetup(), http: httpServiceMock.createInternalSetupContract(), executionContext: executionContextServiceMock.createInternalSetupContract(), }; diff --git a/src/core/server/elasticsearch/elasticsearch_service.ts b/src/core/server/elasticsearch/elasticsearch_service.ts index d0cf23c5394166f..09e8b3172c8e757 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.ts @@ -9,6 +9,8 @@ import { firstValueFrom, Observable, Subject } from 'rxjs'; import { map, shareReplay, takeUntil } from 'rxjs/operators'; +import { registerAnalyticsContextProvider } from './register_analytics_context_provider'; +import { AnalyticsServiceSetup } from '../analytics'; import { CoreService } from '../../types'; import { CoreContext } from '../core_context'; import { Logger } from '../logging'; @@ -29,8 +31,10 @@ import { isValidConnection } from './is_valid_connection'; import { isInlineScriptingEnabled } from './is_scripting_enabled'; import type { UnauthorizedErrorHandler } from './client/retry_unauthorized'; import { mergeConfig } from './merge_config'; +import { getClusterInfo$ } from './get_cluster_info'; export interface SetupDeps { + analytics: AnalyticsServiceSetup; http: InternalHttpServiceSetup; executionContext: InternalExecutionContextSetup; } @@ -92,10 +96,14 @@ export class ElasticsearchService this.esNodesCompatibility$ = esNodesCompatibility$; + const clusterInfo$ = getClusterInfo$(this.client.asInternalUser); + registerAnalyticsContextProvider(deps.analytics, clusterInfo$); + return { legacy: { config$: this.config$, }, + clusterInfo$, esNodesCompatibility$, status$: calculateStatus$(esNodesCompatibility$), setUnauthorizedErrorHandler: (handler) => { diff --git a/src/core/server/elasticsearch/get_cluster_info.test.ts b/src/core/server/elasticsearch/get_cluster_info.test.ts new file mode 100644 index 000000000000000..fd3b3b71844acfd --- /dev/null +++ b/src/core/server/elasticsearch/get_cluster_info.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { elasticsearchClientMock } from './client/mocks'; +import { firstValueFrom } from 'rxjs'; +import { getClusterInfo$ } from './get_cluster_info'; + +describe('getClusterInfo', () => { + let internalClient: ReturnType; + const infoResponse = { + cluster_name: 'cluster-name', + cluster_uuid: 'cluster_uuid', + name: 'name', + tagline: 'tagline', + version: { + number: '1.2.3', + lucene_version: '1.2.3', + build_date: 'DateString', + build_flavor: 'string', + build_hash: 'string', + build_snapshot: true, + build_type: 'string', + minimum_index_compatibility_version: '1.2.3', + minimum_wire_compatibility_version: '1.2.3', + }, + }; + + beforeEach(() => { + internalClient = elasticsearchClientMock.createInternalClient(); + }); + + test('it provides the context', async () => { + internalClient.info.mockResolvedValue(infoResponse); + const context$ = getClusterInfo$(internalClient); + await expect(firstValueFrom(context$)).resolves.toMatchInlineSnapshot(` + Object { + "cluster_name": "cluster-name", + "cluster_uuid": "cluster_uuid", + "cluster_version": "1.2.3", + } + `); + }); + + test('it retries if it fails to fetch the cluster info', async () => { + internalClient.info.mockRejectedValueOnce(new Error('Failed to fetch cluster info')); + internalClient.info.mockResolvedValue(infoResponse); + const context$ = getClusterInfo$(internalClient); + await expect(firstValueFrom(context$)).resolves.toMatchInlineSnapshot(` + Object { + "cluster_name": "cluster-name", + "cluster_uuid": "cluster_uuid", + "cluster_version": "1.2.3", + } + `); + expect(internalClient.info).toHaveBeenCalledTimes(2); + }); + + test('multiple subscribers do not trigger more ES requests', async () => { + internalClient.info.mockResolvedValue(infoResponse); + const context$ = getClusterInfo$(internalClient); + await expect(firstValueFrom(context$)).resolves.toMatchInlineSnapshot(` + Object { + "cluster_name": "cluster-name", + "cluster_uuid": "cluster_uuid", + "cluster_version": "1.2.3", + } + `); + await expect(firstValueFrom(context$)).resolves.toMatchInlineSnapshot(` + Object { + "cluster_name": "cluster-name", + "cluster_uuid": "cluster_uuid", + "cluster_version": "1.2.3", + } + `); + expect(internalClient.info).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/core/server/elasticsearch/get_cluster_info.ts b/src/core/server/elasticsearch/get_cluster_info.ts new file mode 100644 index 000000000000000..c807965d3bbf8a8 --- /dev/null +++ b/src/core/server/elasticsearch/get_cluster_info.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 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 { Observable } from 'rxjs'; +import { defer, map, retry, shareReplay } from 'rxjs'; +import type { ElasticsearchClient } from './client'; + +/** @private */ +export interface ClusterInfo { + cluster_name: string; + cluster_uuid: string; + cluster_version: string; +} + +/** + * Returns the cluster info from the Elasticsearch cluster. + * @param internalClient Elasticsearch client + * @private + */ +export function getClusterInfo$(internalClient: ElasticsearchClient): Observable { + return defer(() => internalClient.info()).pipe( + map((info) => ({ + cluster_name: info.cluster_name, + cluster_uuid: info.cluster_uuid, + cluster_version: info.version.number, + })), + retry({ delay: 1000 }), + shareReplay(1) + ); +} diff --git a/src/core/server/elasticsearch/register_analytics_context_provider.test.ts b/src/core/server/elasticsearch/register_analytics_context_provider.test.ts new file mode 100644 index 000000000000000..4f09ea8677f44e6 --- /dev/null +++ b/src/core/server/elasticsearch/register_analytics_context_provider.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { firstValueFrom, of } from 'rxjs'; +import type { AnalyticsServiceSetup } from '../analytics'; +import { analyticsServiceMock } from '../analytics/analytics_service.mock'; +import { registerAnalyticsContextProvider } from './register_analytics_context_provider'; + +describe('registerAnalyticsContextProvider', () => { + let analyticsMock: jest.Mocked; + + beforeEach(() => { + analyticsMock = analyticsServiceMock.createAnalyticsServiceSetup(); + }); + + test('it provides the context', async () => { + registerAnalyticsContextProvider( + analyticsMock, + of({ cluster_name: 'cluster-name', cluster_uuid: 'cluster_uuid', cluster_version: '1.2.3' }) + ); + const { context$ } = analyticsMock.registerContextProvider.mock.calls[0][0]; + await expect(firstValueFrom(context$)).resolves.toMatchInlineSnapshot(` + Object { + "cluster_name": "cluster-name", + "cluster_uuid": "cluster_uuid", + "cluster_version": "1.2.3", + } + `); + }); +}); diff --git a/src/core/server/elasticsearch/register_analytics_context_provider.ts b/src/core/server/elasticsearch/register_analytics_context_provider.ts new file mode 100644 index 000000000000000..cc4523c0d4eb522 --- /dev/null +++ b/src/core/server/elasticsearch/register_analytics_context_provider.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { Observable } from 'rxjs'; +import type { AnalyticsServiceSetup } from '../analytics'; +import type { ClusterInfo } from './get_cluster_info'; + +/** + * Registers the Analytics context provider to enrich events with the cluster info. + * @param analytics Analytics service. + * @param context$ Observable emitting the cluster info. + * @private + */ +export function registerAnalyticsContextProvider( + analytics: AnalyticsServiceSetup, + context$: Observable +) { + analytics.registerContextProvider({ + name: 'elasticsearch info', + context$, + schema: { + cluster_name: { type: 'keyword', _meta: { description: 'The Cluster Name' } }, + cluster_uuid: { type: 'keyword', _meta: { description: 'The Cluster UUID' } }, + cluster_version: { type: 'keyword', _meta: { description: 'The Cluster version' } }, + }, + }); +} diff --git a/src/core/server/elasticsearch/types.ts b/src/core/server/elasticsearch/types.ts index 1f363804b3a338c..12ba2575d2726c9 100644 --- a/src/core/server/elasticsearch/types.ts +++ b/src/core/server/elasticsearch/types.ts @@ -14,6 +14,7 @@ import { IClusterClient, ICustomClusterClient, ElasticsearchClientConfig } from import { NodesVersionCompatibility } from './version_check/ensure_es_version'; import { ServiceStatus } from '../status'; import type { UnauthorizedErrorHandler } from './client/retry_unauthorized'; +import { ClusterInfo } from './get_cluster_info'; /** * @public @@ -97,6 +98,7 @@ export type InternalElasticsearchServicePreboot = ElasticsearchServicePreboot; /** @internal */ export interface InternalElasticsearchServiceSetup extends ElasticsearchServiceSetup { + clusterInfo$: Observable; esNodesCompatibility$: Observable; status$: Observable>; } diff --git a/src/core/server/environment/environment_service.test.ts b/src/core/server/environment/environment_service.test.ts index 0817fad35f882ee..c285edc443ce869 100644 --- a/src/core/server/environment/environment_service.test.ts +++ b/src/core/server/environment/environment_service.test.ts @@ -13,10 +13,12 @@ import { resolveInstanceUuid } from './resolve_uuid'; import { createDataFolder } from './create_data_folder'; import { writePidFile } from './write_pid_file'; import { CoreContext } from '../core_context'; +import type { AnalyticsServicePreboot } from '../analytics'; import { configServiceMock } from '../config/mocks'; import { loggingSystemMock } from '../logging/logging_system.mock'; import { mockCoreContext } from '../core_context.mock'; +import { analyticsServiceMock } from '../analytics/analytics_service.mock'; jest.mock('./resolve_uuid', () => ({ resolveInstanceUuid: jest.fn().mockResolvedValue('SOME_UUID'), @@ -63,11 +65,13 @@ describe('UuidService', () => { let configService: ReturnType; let coreContext: CoreContext; let service: EnvironmentService; + let analytics: AnalyticsServicePreboot; beforeEach(async () => { logger = loggingSystemMock.create(); configService = getConfigService(); coreContext = mockCoreContext.create({ logger, configService }); + analytics = analyticsServiceMock.createAnalyticsServicePreboot(); service = new EnvironmentService(coreContext); }); @@ -78,7 +82,7 @@ describe('UuidService', () => { describe('#preboot()', () => { it('calls resolveInstanceUuid with correct parameters', async () => { - await service.preboot(); + await service.preboot({ analytics }); expect(resolveInstanceUuid).toHaveBeenCalledTimes(1); expect(resolveInstanceUuid).toHaveBeenCalledWith({ @@ -89,7 +93,7 @@ describe('UuidService', () => { }); it('calls createDataFolder with correct parameters', async () => { - await service.preboot(); + await service.preboot({ analytics }); expect(createDataFolder).toHaveBeenCalledTimes(1); expect(createDataFolder).toHaveBeenCalledWith({ @@ -99,7 +103,7 @@ describe('UuidService', () => { }); it('calls writePidFile with correct parameters', async () => { - await service.preboot(); + await service.preboot({ analytics }); expect(writePidFile).toHaveBeenCalledTimes(1); expect(writePidFile).toHaveBeenCalledWith({ @@ -109,14 +113,14 @@ describe('UuidService', () => { }); it('returns the uuid resolved from resolveInstanceUuid', async () => { - const preboot = await service.preboot(); + const preboot = await service.preboot({ analytics }); expect(preboot.instanceUuid).toEqual('SOME_UUID'); }); describe('process warnings', () => { it('logs warnings coming from the process', async () => { - await service.preboot(); + await service.preboot({ analytics }); const warning = new Error('something went wrong'); process.emit('warning', warning); @@ -126,7 +130,7 @@ describe('UuidService', () => { }); it('does not log deprecation warnings', async () => { - await service.preboot(); + await service.preboot({ analytics }); const warning = new Error('something went wrong'); warning.name = 'DeprecationWarning'; @@ -139,7 +143,7 @@ describe('UuidService', () => { // TODO: From Nodejs v16 emitting an unhandledRejection will kill the process describe.skip('unhandledRejection warnings', () => { it('logs warn for an unhandeld promise rejected with an Error', async () => { - await service.preboot(); + await service.preboot({ analytics }); const err = new Error('something went wrong'); process.emit('unhandledRejection', err, new Promise((res, rej) => rej(err))); @@ -151,7 +155,7 @@ describe('UuidService', () => { }); it('logs warn for an unhandeld promise rejected with a string', async () => { - await service.preboot(); + await service.preboot({ analytics }); const err = 'something went wrong'; process.emit('unhandledRejection', err, new Promise((res, rej) => rej(err))); @@ -166,7 +170,7 @@ describe('UuidService', () => { describe('#setup()', () => { it('returns the uuid resolved from resolveInstanceUuid', async () => { - await expect(service.preboot()).resolves.toEqual({ instanceUuid: 'SOME_UUID' }); + await expect(service.preboot({ analytics })).resolves.toEqual({ instanceUuid: 'SOME_UUID' }); expect(service.setup()).toEqual({ instanceUuid: 'SOME_UUID' }); }); }); diff --git a/src/core/server/environment/environment_service.ts b/src/core/server/environment/environment_service.ts index 65c03b108b28a0c..28e2da446eb9587 100644 --- a/src/core/server/environment/environment_service.ts +++ b/src/core/server/environment/environment_service.ts @@ -6,8 +6,9 @@ * Side Public License, v 1. */ -import { firstValueFrom } from 'rxjs'; +import { firstValueFrom, of } from 'rxjs'; import { PathConfigType, config as pathConfigDef } from '@kbn/utils'; +import type { AnalyticsServicePreboot } from '../analytics'; import { CoreContext } from '../core_context'; import { Logger } from '../logging'; import { IConfigService } from '../config'; @@ -17,6 +18,16 @@ import { resolveInstanceUuid } from './resolve_uuid'; import { createDataFolder } from './create_data_folder'; import { writePidFile } from './write_pid_file'; +/** + * @internal + */ +export interface PrebootDeps { + /** + * {@link AnalyticsServicePreboot} + */ + analytics: AnalyticsServicePreboot; +} + /** * @internal */ @@ -45,7 +56,7 @@ export class EnvironmentService { this.configService = core.configService; } - public async preboot() { + public async preboot({ analytics }: PrebootDeps) { // IMPORTANT: This code is based on the assumption that none of the configuration values used // here is supposed to change during preboot phase and it's safe to read them only once. const [pathConfig, serverConfig, pidConfig] = await Promise.all([ @@ -77,6 +88,24 @@ export class EnvironmentService { logger: this.log, }); + analytics.registerContextProvider({ + name: 'kibana info', + context$: of({ + kibana_uuid: this.uuid, + pid: process.pid, + }), + schema: { + kibana_uuid: { + type: 'keyword', + _meta: { description: 'Kibana instance UUID' }, + }, + pid: { + type: 'long', + _meta: { description: 'Process ID' }, + }, + }, + }); + return { instanceUuid: this.uuid, }; diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index f251d3fb64cabdd..557a10da0839de3 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -45,8 +45,9 @@ export type HttpServiceSetupMock = jest.Mocked< createRouter: jest.MockedFunction<() => RouterMock>; }; export type InternalHttpServiceSetupMock = jest.Mocked< - Omit + Omit > & { + auth: AuthMocked; basePath: BasePathMocked; createRouter: jest.MockedFunction<(path: string) => RouterMock>; authRequestHeaders: jest.Mocked; diff --git a/src/core/server/rendering/__mocks__/params.ts b/src/core/server/rendering/__mocks__/params.ts index 091d185cceefced..b4ead2e628688e0 100644 --- a/src/core/server/rendering/__mocks__/params.ts +++ b/src/core/server/rendering/__mocks__/params.ts @@ -7,6 +7,7 @@ */ import { mockCoreContext } from '../../core_context.mock'; +import { elasticsearchServiceMock } from '../../elasticsearch/elasticsearch_service.mock'; import { httpServiceMock } from '../../http/http_service.mock'; import { pluginServiceMock } from '../../plugins/plugins_service.mock'; import { statusServiceMock } from '../../status/status_service.mock'; @@ -15,6 +16,7 @@ const context = mockCoreContext.create(); const httpPreboot = httpServiceMock.createInternalPrebootContract(); const httpSetup = httpServiceMock.createInternalSetupContract(); const status = statusServiceMock.createInternalSetupContract(); +const elasticsearch = elasticsearchServiceMock.createInternalSetup(); export const mockRenderingServiceParams = context; export const mockRenderingPrebootDeps = { @@ -22,6 +24,7 @@ export const mockRenderingPrebootDeps = { uiPlugins: pluginServiceMock.createUiPlugins(), }; export const mockRenderingSetupDeps = { + elasticsearch, http: httpSetup, uiPlugins: pluginServiceMock.createUiPlugins(), status, diff --git a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap index 4abf24911808c6d..9fe0cb545e7aa22 100644 --- a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap +++ b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap @@ -6,6 +6,7 @@ Object { "basePath": "/mock-server-basepath", "branch": Any, "buildNumber": Any, + "clusterInfo": Object {}, "csp": Object { "warnLegacyBrowsers": true, }, @@ -61,6 +62,7 @@ Object { "basePath": "/mock-server-basepath", "branch": Any, "buildNumber": Any, + "clusterInfo": Object {}, "csp": Object { "warnLegacyBrowsers": true, }, @@ -120,6 +122,7 @@ Object { "basePath": "", "branch": Any, "buildNumber": Any, + "clusterInfo": Object {}, "csp": Object { "warnLegacyBrowsers": true, }, @@ -169,12 +172,69 @@ Object { } `; +exports[`RenderingService preboot() render() renders "core" page for unauthenticated requests 1`] = ` +Object { + "anonymousStatusPage": false, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "clusterInfo": Object {}, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "mode": Object { + "dev": Any, + "name": Any, + "prod": Any, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": Any, + "version": Any, + }, + }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + }, + "publicBaseUrl": "http://myhost.com/mock-server-basepath", + "serverBasePath": "/mock-server-basepath", + "theme": Object { + "darkMode": "theme:darkMode", + "version": "v8", + }, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + exports[`RenderingService preboot() render() renders "core" with excluded user settings 1`] = ` Object { "anonymousStatusPage": false, "basePath": "/mock-server-basepath", "branch": Any, "buildNumber": Any, + "clusterInfo": Object {}, "csp": Object { "warnLegacyBrowsers": true, }, @@ -230,6 +290,7 @@ Object { "basePath": "/mock-server-basepath", "branch": Any, "buildNumber": Any, + "clusterInfo": Object {}, "csp": Object { "warnLegacyBrowsers": true, }, @@ -285,6 +346,11 @@ Object { "basePath": "/mock-server-basepath", "branch": Any, "buildNumber": Any, + "clusterInfo": Object { + "cluster_name": "cluster-name", + "cluster_uuid": "cluster-uuid", + "cluster_version": "8.0.0", + }, "csp": Object { "warnLegacyBrowsers": true, }, @@ -344,6 +410,11 @@ Object { "basePath": "", "branch": Any, "buildNumber": Any, + "clusterInfo": Object { + "cluster_name": "cluster-name", + "cluster_uuid": "cluster-uuid", + "cluster_version": "8.0.0", + }, "csp": Object { "warnLegacyBrowsers": true, }, @@ -393,12 +464,73 @@ Object { } `; +exports[`RenderingService setup() render() renders "core" page for unauthenticated requests 1`] = ` +Object { + "anonymousStatusPage": false, + "basePath": "/mock-server-basepath", + "branch": Any, + "buildNumber": Any, + "clusterInfo": Object {}, + "csp": Object { + "warnLegacyBrowsers": true, + }, + "env": Object { + "mode": Object { + "dev": Any, + "name": Any, + "prod": Any, + }, + "packageInfo": Object { + "branch": Any, + "buildNum": Any, + "buildSha": Any, + "dist": Any, + "version": Any, + }, + }, + "externalUrl": Object { + "policy": Array [ + Object { + "allow": true, + }, + ], + }, + "i18n": Object { + "translationsUrl": "/mock-server-basepath/translations/en.json", + }, + "legacyMetadata": Object { + "uiSettings": Object { + "defaults": Object { + "registered": Object { + "name": "title", + }, + }, + "user": Object {}, + }, + }, + "publicBaseUrl": "http://myhost.com/mock-server-basepath", + "serverBasePath": "/mock-server-basepath", + "theme": Object { + "darkMode": "theme:darkMode", + "version": "v8", + }, + "uiPlugins": Array [], + "vars": Object {}, + "version": Any, +} +`; + exports[`RenderingService setup() render() renders "core" with excluded user settings 1`] = ` Object { "anonymousStatusPage": false, "basePath": "/mock-server-basepath", "branch": Any, "buildNumber": Any, + "clusterInfo": Object { + "cluster_name": "cluster-name", + "cluster_uuid": "cluster-uuid", + "cluster_version": "8.0.0", + }, "csp": Object { "warnLegacyBrowsers": true, }, diff --git a/src/core/server/rendering/rendering_service.test.ts b/src/core/server/rendering/rendering_service.test.ts index cb10d01e857739b..8aecc536d8846de 100644 --- a/src/core/server/rendering/rendering_service.test.ts +++ b/src/core/server/rendering/rendering_service.test.ts @@ -25,6 +25,7 @@ import { } from './__mocks__/params'; import { InternalRenderingServicePreboot, InternalRenderingServiceSetup } from './types'; import { RenderingService } from './rendering_service'; +import { AuthStatus } from '../http/auth_state_storage'; const INJECTED_METADATA = { version: expect.any(String), @@ -75,6 +76,23 @@ function renderTestCases( expect(data).toMatchSnapshot(INJECTED_METADATA); }); + it('renders "core" page for unauthenticated requests', async () => { + mockRenderingSetupDeps.http.auth.get.mockReturnValueOnce({ + status: AuthStatus.unauthenticated, + state: {}, + }); + + const [render] = await getRender(); + const content = await render( + createKibanaRequest({ auth: { isAuthenticated: false } }), + uiSettings + ); + const dom = load(content); + const data = JSON.parse(dom('kbn-injected-metadata').attr('data') ?? '""'); + + expect(data).toMatchSnapshot(INJECTED_METADATA); + }); + it('renders "core" page for blank basepath', async () => { const [render, deps] = await getRender(); deps.http.basePath.get.mockReturnValueOnce(''); diff --git a/src/core/server/rendering/rendering_service.tsx b/src/core/server/rendering/rendering_service.tsx index 73746a8f202ffec..3e50aac6fcbdda8 100644 --- a/src/core/server/rendering/rendering_service.tsx +++ b/src/core/server/rendering/rendering_service.tsx @@ -8,10 +8,11 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { take } from 'rxjs/operators'; +import { catchError, take, timeout } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; import type { ThemeVersion } from '@kbn/ui-shared-deps-npm'; +import { firstValueFrom, of } from 'rxjs'; import type { UiPlugins } from '../plugins'; import { CoreContext } from '../core_context'; import { Template } from './views'; @@ -25,11 +26,13 @@ import { } from './types'; import { registerBootstrapRoute, bootstrapRendererFactory } from './bootstrap'; import { getSettingValue, getStylesheetPaths } from './render_utils'; -import { KibanaRequest } from '../http'; +import type { HttpAuth, KibanaRequest } from '../http'; import { IUiSettingsClient } from '../ui_settings'; import { filterUiPlugins } from './filter_ui_plugins'; -type RenderOptions = (RenderingPrebootDeps & { status?: never }) | RenderingSetupDeps; +type RenderOptions = + | (RenderingPrebootDeps & { status?: never; elasticsearch?: never }) + | RenderingSetupDeps; /** @internal */ export class RenderingService { @@ -57,6 +60,7 @@ export class RenderingService { } public async setup({ + elasticsearch, http, status, uiPlugins, @@ -72,12 +76,12 @@ export class RenderingService { }); return { - render: this.render.bind(this, { http, uiPlugins, status }), + render: this.render.bind(this, { elasticsearch, http, uiPlugins, status }), }; } private async render( - { http, uiPlugins, status }: RenderOptions, + { elasticsearch, http, uiPlugins, status }: RenderOptions, request: KibanaRequest, uiSettings: IUiSettingsClient, { isAnonymousPage = false, vars, includeExposedConfigKeys }: IRenderOptions = {} @@ -94,6 +98,21 @@ export class RenderingService { user: isAnonymousPage ? {} : await uiSettings.getUserProvided(), }; + let clusterInfo = {}; + try { + // Only provide the clusterInfo if the request is authenticated and the elasticsearch service is available. + if (isAuthenticated(http.auth, request) && elasticsearch) { + clusterInfo = await firstValueFrom( + elasticsearch.clusterInfo$.pipe( + timeout(50), // If not available, just return undefined + catchError(() => of({})) + ) + ); + } + } catch (err) { + // swallow error + } + const darkMode = getSettingValue('theme:darkMode', settings, Boolean); const themeVersion: ThemeVersion = 'v8'; @@ -123,6 +142,7 @@ export class RenderingService { serverBasePath, publicBaseUrl, env, + clusterInfo, anonymousStatusPage: status?.isStatusPageAnonymous() ?? false, i18n: { translationsUrl: `${basePath}/translations/${i18n.getLocale()}.json`, @@ -164,3 +184,9 @@ const getUiConfig = async (uiPlugins: UiPlugins, pluginId: string) => { exposedConfigKeys: {}, }) as { browserConfig: Record; exposedConfigKeys: Record }; }; + +const isAuthenticated = (auth: HttpAuth, request: KibanaRequest) => { + const { status: authStatus } = auth.get(request); + // status is 'unknown' when auth is disabled. we just need to not be `unauthenticated` here. + return authStatus !== 'unauthenticated'; +}; diff --git a/src/core/server/rendering/types.ts b/src/core/server/rendering/types.ts index 2c0aafe61e01895..82758018b859d93 100644 --- a/src/core/server/rendering/types.ts +++ b/src/core/server/rendering/types.ts @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import type { ThemeVersion } from '@kbn/ui-shared-deps-npm'; +import { InternalElasticsearchServiceSetup } from '../elasticsearch'; import { EnvironmentMode, PackageInfo } from '../config'; import { ICspConfig } from '../csp'; import { InternalHttpServicePreboot, InternalHttpServiceSetup, KibanaRequest } from '../http'; @@ -38,6 +39,11 @@ export interface InjectedMetadata { basePath: string; serverBasePath: string; publicBaseUrl?: string; + clusterInfo: { + cluster_uuid?: string; + cluster_name?: string; + cluster_version?: string; + }; env: { mode: EnvironmentMode; packageInfo: PackageInfo; @@ -74,6 +80,7 @@ export interface RenderingPrebootDeps { /** @internal */ export interface RenderingSetupDeps { + elasticsearch: InternalElasticsearchServiceSetup; http: InternalHttpServiceSetup; status: InternalStatusServiceSetup; uiPlugins: UiPlugins; diff --git a/src/core/server/saved_objects/migrations/README.md b/src/core/server/saved_objects/migrations/README.md index 7dd5dacffcee695..03bbb0bc731c445 100644 --- a/src/core/server/saved_objects/migrations/README.md +++ b/src/core/server/saved_objects/migrations/README.md @@ -149,8 +149,12 @@ index. ### New control state 1. Two conditions have to be met before migrations begin: - 1. If replica allocation is set as a persistent or transient setting to "perimaries", "new_primaries" or "none" fail the migration. Without replica allocation enabled or not set to 'all', the migration will timeout when waiting for index yellow status before bulk indexing. The check only considers persistent and transient settings and does not take static configuration in `elasticsearch.yml` into account. If `cluster.routing.allocation.enable` is configured in `elaticsearch.yml` and not set to the default of 'all', the migration will timeout. Static settings can only be returned from the `nodes/info` API. - → `FATAL` + 1. The Elasticsearch shard allocation cluster setting `cluster.routing.allocation.enable` needs to be unset or set to 'all'. When set to 'primaries', 'new_primaries' or 'none', the migration will timeout when waiting for index yellow status before bulk indexing because the replica cannot be allocated. + + As per the Elasticsearch docs https://www.elastic.co/guide/en/elasticsearch/reference/8.2/restart-cluster.html#restart-cluster-rolling when Cloud performs a rolling restart such as during an upgrade, it will temporarily disable shard allocation. Kibana therefore keeps retrying the INIT step to wait for shard allocation to be enabled again. + + The check only considers persistent and transient settings and does not take static configuration in `elasticsearch.yml` into account since there are no known use cases for doing so. If `cluster.routing.allocation.enable` is configured in `elaticsearch.yml` and not set to the default of 'all', the migration will timeout. Static settings can only be returned from the `nodes/info` API. + → `INIT` 2. If `.kibana` is pointing to an index that belongs to a later version of Kibana .e.g. a 7.11.0 instance found the `.kibana` alias pointing to diff --git a/src/core/server/saved_objects/migrations/actions/index.ts b/src/core/server/saved_objects/migrations/actions/index.ts index 4db260d4c139bb2..74d8c57ebf171d3 100644 --- a/src/core/server/saved_objects/migrations/actions/index.ts +++ b/src/core/server/saved_objects/migrations/actions/index.ts @@ -20,7 +20,7 @@ export { export type { RetryableEsClientError }; // actions/* imports -export type { InitActionParams, UnsupportedClusterRoutingAllocation } from './initialize_action'; +export type { InitActionParams, IncompatibleClusterRoutingAllocation } from './initialize_action'; export { initAction } from './initialize_action'; export type { FetchIndexResponse, FetchIndicesParams } from './fetch_indices'; @@ -87,7 +87,7 @@ export type { export { updateAndPickupMappings } from './update_and_pickup_mappings'; import type { UnknownDocsFound } from './check_for_unknown_docs'; -import type { UnsupportedClusterRoutingAllocation } from './initialize_action'; +import type { IncompatibleClusterRoutingAllocation } from './initialize_action'; export type { CheckForUnknownDocsParams, @@ -151,7 +151,7 @@ export interface ActionErrorTypeMap { documents_transform_failed: DocumentsTransformFailed; request_entity_too_large_exception: RequestEntityTooLargeException; unknown_docs_found: UnknownDocsFound; - unsupported_cluster_routing_allocation: UnsupportedClusterRoutingAllocation; + incompatible_cluster_routing_allocation: IncompatibleClusterRoutingAllocation; index_not_yellow_timeout: IndexNotYellowTimeout; } diff --git a/src/core/server/saved_objects/migrations/actions/initialize_action.test.ts b/src/core/server/saved_objects/migrations/actions/initialize_action.test.ts index 7c75470b890aa3b..5d831a5bb8f788a 100644 --- a/src/core/server/saved_objects/migrations/actions/initialize_action.test.ts +++ b/src/core/server/saved_objects/migrations/actions/initialize_action.test.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import * as Either from 'fp-ts/lib/Either'; import { catchRetryableEsClientErrors } from './catch_retryable_es_client_errors'; import { errors as EsErrors } from '@elastic/elasticsearch'; jest.mock('./catch_retryable_es_client_errors'); @@ -16,16 +17,16 @@ describe('initAction', () => { beforeEach(() => { jest.clearAllMocks(); }); - const retryableError = new EsErrors.ResponseError( - elasticsearchClientMock.createApiResponse({ - statusCode: 503, - body: { error: { type: 'es_type', reason: 'es_reason' } }, - }) - ); - const client = elasticsearchClientMock.createInternalClient( - elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) - ); it('calls catchRetryableEsClientErrors when the promise rejects', async () => { + const retryableError = new EsErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 503, + body: { error: { type: 'es_type', reason: 'es_reason' } }, + }) + ); + const client = elasticsearchClientMock.createInternalClient( + elasticsearchClientMock.createErrorTransportRequestPromise(retryableError) + ); const task = initAction({ client, indices: ['my_index'] }); try { await task(); @@ -34,4 +35,88 @@ describe('initAction', () => { } expect(catchRetryableEsClientErrors).toHaveBeenCalledWith(retryableError); }); + it('resolves right when persistent and transient cluster settings are compatible', async () => { + const clusterSettingsResponse = { + transient: { 'cluster.routing.allocation.enable': 'all' }, + persistent: { 'cluster.routing.allocation.enable': 'all' }, + }; + const client = elasticsearchClientMock.createInternalClient( + new Promise((res) => res(clusterSettingsResponse)) + ); + const task = initAction({ client, indices: ['my_index'] }); + const result = await task(); + expect(Either.isRight(result)).toEqual(true); + }); + it('resolves right when persistent and transient cluster settings are undefined', async () => { + const clusterSettingsResponse = { + transient: {}, + persistent: {}, + }; + const client = elasticsearchClientMock.createInternalClient( + new Promise((res) => res(clusterSettingsResponse)) + ); + const task = initAction({ client, indices: ['my_index'] }); + const result = await task(); + expect(Either.isRight(result)).toEqual(true); + }); + it('resolves right when persistent cluster settings are compatible', async () => { + const clusterSettingsResponse = { + transient: {}, + persistent: { 'cluster.routing.allocation.enable': 'all' }, + }; + const client = elasticsearchClientMock.createInternalClient( + new Promise((res) => res(clusterSettingsResponse)) + ); + const task = initAction({ client, indices: ['my_index'] }); + const result = await task(); + expect(Either.isRight(result)).toEqual(true); + }); + it('resolves right when transient cluster settings are compatible', async () => { + const clusterSettingsResponse = { + transient: { 'cluster.routing.allocation.enable': 'all' }, + persistent: {}, + }; + const client = elasticsearchClientMock.createInternalClient( + new Promise((res) => res(clusterSettingsResponse)) + ); + const task = initAction({ client, indices: ['my_index'] }); + const result = await task(); + expect(Either.isRight(result)).toEqual(true); + }); + it('resolves right when valid transient settings, incompatible persistent settings', async () => { + const clusterSettingsResponse = { + transient: { 'cluster.routing.allocation.enable': 'all' }, + persistent: { 'cluster.routing.allocation.enable': 'primaries' }, + }; + const client = elasticsearchClientMock.createInternalClient( + new Promise((res) => res(clusterSettingsResponse)) + ); + const task = initAction({ client, indices: ['my_index'] }); + const result = await task(); + expect(Either.isRight(result)).toEqual(true); + }); + it('resolves left when valid persistent settings, incompatible transient settings', async () => { + const clusterSettingsResponse = { + transient: { 'cluster.routing.allocation.enable': 'primaries' }, + persistent: { 'cluster.routing.allocation.enable': 'alls' }, + }; + const client = elasticsearchClientMock.createInternalClient( + new Promise((res) => res(clusterSettingsResponse)) + ); + const task = initAction({ client, indices: ['my_index'] }); + const result = await task(); + expect(Either.isLeft(result)).toEqual(true); + }); + it('resolves left when transient cluster settings are incompatible', async () => { + const clusterSettingsResponse = { + transient: { 'cluster.routing.allocation.enable': 'none' }, + persistent: { 'cluster.routing.allocation.enable': 'all' }, + }; + const client = elasticsearchClientMock.createInternalClient( + new Promise((res) => res(clusterSettingsResponse)) + ); + const task = initAction({ client, indices: ['my_index'] }); + const result = await task(); + expect(Either.isLeft(result)).toEqual(true); + }); }); diff --git a/src/core/server/saved_objects/migrations/actions/initialize_action.ts b/src/core/server/saved_objects/migrations/actions/initialize_action.ts index 281e3a0a4f3e031..b797d81a46ec3e8 100644 --- a/src/core/server/saved_objects/migrations/actions/initialize_action.ts +++ b/src/core/server/saved_objects/migrations/actions/initialize_action.ts @@ -27,9 +27,8 @@ export interface InitActionParams { indices: string[]; } -export interface UnsupportedClusterRoutingAllocation { - type: 'unsupported_cluster_routing_allocation'; - message: string; +export interface IncompatibleClusterRoutingAllocation { + type: 'incompatible_cluster_routing_allocation'; } export const checkClusterRoutingAllocationEnabledTask = @@ -37,27 +36,24 @@ export const checkClusterRoutingAllocationEnabledTask = client, }: { client: ElasticsearchClient; - }): TaskEither.TaskEither => + }): TaskEither.TaskEither => () => { return client.cluster .getSettings({ flat_settings: true, }) .then((settings) => { - const clusterRoutingAllocations: string[] = + // transient settings take preference over persistent settings + const clusterRoutingAllocation = settings?.transient?.[routingAllocationEnable] ?? - settings?.persistent?.[routingAllocationEnable] ?? - []; + settings?.persistent?.[routingAllocationEnable]; - const clusterRoutingAllocationEnabled = - [...clusterRoutingAllocations].length === 0 || - [...clusterRoutingAllocations].every((s: string) => s === 'all'); // if set, only allow 'all' + const clusterRoutingAllocationEnabledIsAll = + clusterRoutingAllocation === undefined || clusterRoutingAllocation === 'all'; - if (!clusterRoutingAllocationEnabled) { + if (!clusterRoutingAllocationEnabledIsAll) { return Either.left({ - type: 'unsupported_cluster_routing_allocation' as const, - message: - '[unsupported_cluster_routing_allocation] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue.', + type: 'incompatible_cluster_routing_allocation' as const, }); } else { return Either.right({}); @@ -70,7 +66,7 @@ export const initAction = ({ client, indices, }: InitActionParams): TaskEither.TaskEither< - RetryableEsClientError | UnsupportedClusterRoutingAllocation, + RetryableEsClientError | IncompatibleClusterRoutingAllocation, FetchIndexResponse > => { return pipe( diff --git a/src/core/server/saved_objects/migrations/actions/integration_tests/actions.test.ts b/src/core/server/saved_objects/migrations/actions/integration_tests/actions.test.ts index 9846e5f48dc2175..7ac6911ec7e9ef9 100644 --- a/src/core/server/saved_objects/migrations/actions/integration_tests/actions.test.ts +++ b/src/core/server/saved_objects/migrations/actions/integration_tests/actions.test.ts @@ -116,7 +116,7 @@ describe('migration actions', () => { await client.cluster.putSettings({ body: { persistent: { - // Remove persistent test settings + // Reset persistent test settings cluster: { routing: { allocation: { enable: null } } }, }, }, @@ -126,11 +126,11 @@ describe('migration actions', () => { expect.assertions(1); const task = initAction({ client, indices: ['no_such_index'] }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": Object {}, - } - `); + Object { + "_tag": "Right", + "right": Object {}, + } + `); }); it('resolves right record with found indices', async () => { expect.assertions(1); @@ -149,7 +149,7 @@ describe('migration actions', () => { }) ); }); - it('resolves left with cluster routing allocation disabled', async () => { + it('resolves left when cluster.routing.allocation.enabled is incompatible', async () => { expect.assertions(3); await client.cluster.putSettings({ body: { @@ -164,14 +164,13 @@ describe('migration actions', () => { indices: ['existing_index_with_docs'], }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Left", - "left": Object { - "message": "[unsupported_cluster_routing_allocation] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue.", - "type": "unsupported_cluster_routing_allocation", - }, - } - `); + Object { + "_tag": "Left", + "left": Object { + "type": "incompatible_cluster_routing_allocation", + }, + } + `); await client.cluster.putSettings({ body: { persistent: { @@ -185,14 +184,13 @@ describe('migration actions', () => { indices: ['existing_index_with_docs'], }); await expect(task2()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Left", - "left": Object { - "message": "[unsupported_cluster_routing_allocation] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue.", - "type": "unsupported_cluster_routing_allocation", - }, - } - `); + Object { + "_tag": "Left", + "left": Object { + "type": "incompatible_cluster_routing_allocation", + }, + } + `); await client.cluster.putSettings({ body: { persistent: { @@ -206,14 +204,29 @@ describe('migration actions', () => { indices: ['existing_index_with_docs'], }); await expect(task3()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Left", - "left": Object { - "message": "[unsupported_cluster_routing_allocation] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue.", - "type": "unsupported_cluster_routing_allocation", - }, - } - `); + Object { + "_tag": "Left", + "left": Object { + "type": "incompatible_cluster_routing_allocation", + }, + } + `); + }); + it('resolves right when cluster.routing.allocation.enabled=all', async () => { + expect.assertions(1); + await client.cluster.putSettings({ + body: { + persistent: { + cluster: { routing: { allocation: { enable: 'all' } } }, + }, + }, + }); + const task = initAction({ + client, + indices: ['existing_index_with_docs'], + }); + const result = await task(); + expect(Either.isRight(result)).toBe(true); }); }); @@ -271,14 +284,14 @@ describe('migration actions', () => { expect.assertions(1); const task = setWriteBlock({ client, index: 'no_such_index' }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Left", - "left": Object { - "index": "no_such_index", - "type": "index_not_found_exception", - }, - } - `); + Object { + "_tag": "Left", + "left": Object { + "index": "no_such_index", + "type": "index_not_found_exception", + }, + } + `); }); }); @@ -300,21 +313,21 @@ describe('migration actions', () => { expect.assertions(1); const task = removeWriteBlock({ client, index: 'existing_index_with_write_block_2' }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": "remove_write_block_succeeded", - } - `); + Object { + "_tag": "Right", + "right": "remove_write_block_succeeded", + } + `); }); it('resolves right if successful when an index does not have a write block', async () => { expect.assertions(1); const task = removeWriteBlock({ client, index: 'existing_index_without_write_block_2' }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": "remove_write_block_succeeded", - } - `); + Object { + "_tag": "Right", + "right": "remove_write_block_succeeded", + } + `); }); it('rejects if there is a non-retryable error', async () => { expect.assertions(1); @@ -398,14 +411,14 @@ describe('migration actions', () => { timeout: '1s', }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Left", - "left": Object { - "message": "[index_not_yellow_timeout] Timeout waiting for the status of the [red_index] index to become 'yellow'", - "type": "index_not_yellow_timeout", - }, - } - `); + Object { + "_tag": "Left", + "left": Object { + "message": "[index_not_yellow_timeout] Timeout waiting for the status of the [red_index] index to become 'yellow'", + "type": "index_not_yellow_timeout", + }, + } + `); }); }); @@ -425,14 +438,14 @@ describe('migration actions', () => { }); expect.assertions(1); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": Object { - "acknowledged": true, - "shardsAcknowledged": true, - }, - } - `); + Object { + "_tag": "Right", + "right": Object { + "acknowledged": true, + "shardsAcknowledged": true, + }, + } + `); }); it('resolves right after waiting for index status to be yellow if clone target already existed', async () => { expect.assertions(2); @@ -491,14 +504,14 @@ describe('migration actions', () => { expect.assertions(1); const task = cloneIndex({ client, source: 'no_such_index', target: 'clone_target_3' }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Left", - "left": Object { - "index": "no_such_index", - "type": "index_not_found_exception", - }, - } - `); + Object { + "_tag": "Left", + "left": Object { + "index": "no_such_index", + "type": "index_not_found_exception", + }, + } + `); }); it('resolves left with a index_not_yellow_timeout if clone target already exists but takes longer than the specified timeout before turning yellow', async () => { // Create a red index @@ -527,14 +540,14 @@ describe('migration actions', () => { })(); await expect(cloneIndexPromise).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Left", - "left": Object { - "message": "[index_not_yellow_timeout] Timeout waiting for the status of the [clone_red_index] index to become 'yellow'", - "type": "index_not_yellow_timeout", - }, - } - `); + Object { + "_tag": "Left", + "left": Object { + "message": "[index_not_yellow_timeout] Timeout waiting for the status of the [clone_red_index] index to become 'yellow'", + "type": "index_not_yellow_timeout", + }, + } + `); // Now that we know timeouts work, make the index yellow again and call cloneIndex a second time to verify that it completes @@ -555,14 +568,14 @@ describe('migration actions', () => { })(); await expect(cloneIndexPromise2).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": Object { - "acknowledged": true, - "shardsAcknowledged": true, - }, - } - `); + Object { + "_tag": "Right", + "right": Object { + "acknowledged": true, + "shardsAcknowledged": true, + }, + } + `); }); }); @@ -580,11 +593,11 @@ describe('migration actions', () => { })()) as Either.Right; const task = waitForReindexTask({ client, taskId: res.right.taskId, timeout: '10s' }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": "reindex_succeeded", - } - `); + Object { + "_tag": "Right", + "right": "reindex_succeeded", + } + `); const results = ( (await searchForOutdatedDocuments(client, { @@ -620,11 +633,11 @@ describe('migration actions', () => { })()) as Either.Right; const task = waitForReindexTask({ client, taskId: res.right.taskId, timeout: '10s' }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": "reindex_succeeded", - } - `); + Object { + "_tag": "Right", + "right": "reindex_succeeded", + } + `); const results = ( (await searchForOutdatedDocuments(client, { @@ -653,11 +666,11 @@ describe('migration actions', () => { })()) as Either.Right; const task = waitForReindexTask({ client, taskId: res.right.taskId, timeout: '10s' }); await expect(task()).resolves.toMatchInlineSnapshot(` - Object { - "_tag": "Right", - "right": "reindex_succeeded", - } - `); + Object { + "_tag": "Right", + "right": "reindex_succeeded", + } + `); const results = ( (await searchForOutdatedDocuments(client, { batchSize: 1000, diff --git a/src/core/server/saved_objects/migrations/integration_tests/archives/8.0.0_migrated_with_outdated_docs.zip b/src/core/server/saved_objects/migrations/integration_tests/archives/8.0.0_migrated_with_outdated_docs.zip index ca936c6448b4336..1fa9f061e15ffc5 100644 Binary files a/src/core/server/saved_objects/migrations/integration_tests/archives/8.0.0_migrated_with_outdated_docs.zip and b/src/core/server/saved_objects/migrations/integration_tests/archives/8.0.0_migrated_with_outdated_docs.zip differ diff --git a/src/core/server/saved_objects/migrations/integration_tests/cluster_routing_allocation_disabled.test.ts b/src/core/server/saved_objects/migrations/integration_tests/incompatible_cluster_routing_allocation.test.ts similarity index 56% rename from src/core/server/saved_objects/migrations/integration_tests/cluster_routing_allocation_disabled.test.ts rename to src/core/server/saved_objects/migrations/integration_tests/incompatible_cluster_routing_allocation.test.ts index 37b278fe9ccf05e..57efba56936bd84 100644 --- a/src/core/server/saved_objects/migrations/integration_tests/cluster_routing_allocation_disabled.test.ts +++ b/src/core/server/saved_objects/migrations/integration_tests/incompatible_cluster_routing_allocation.test.ts @@ -15,7 +15,7 @@ import { ElasticsearchClient } from '../../../elasticsearch'; import { LogRecord } from '@kbn/logging'; import { retryAsync } from '../test_helpers/retry_async'; -const logFilePath = Path.join(__dirname, 'unsupported_cluster_routing_allocation.log'); +const logFilePath = Path.join(__dirname, 'incompatible_cluster_routing_allocation.log'); async function removeLogFile() { // ignore errors if it doesn't exist @@ -27,7 +27,11 @@ const { startES } = kbnTestServer.createTestServers({ settings: { es: { license: 'basic', - dataArchive: Path.join(__dirname, 'archives', '7.7.2_xpack_100k_obj.zip'), + dataArchive: Path.join( + __dirname, + 'archives', + '8.0.0_v1_migrations_sample_data_saved_objects.zip' + ), }, }, }); @@ -77,14 +81,14 @@ let esServer: kbnTestServer.TestElasticsearchUtils; async function updateRoutingAllocations( esClient: ElasticsearchClient, settingType: string = 'persistent', - value: string = 'none' + value: string | null ) { return await esClient.cluster.putSettings({ [settingType]: { cluster: { routing: { allocation: { enable: value } } } }, }); } -describe('unsupported_cluster_routing_allocation', () => { +describe('incompatible_cluster_routing_allocation', () => { let client: ElasticsearchClient; let root: Root; @@ -97,7 +101,7 @@ describe('unsupported_cluster_routing_allocation', () => { await esServer.stop(); }); - it('fails with a descriptive message when persistent replica allocation is not enabled', async () => { + it('retries the INIT action with a descriptive message when cluster settings are incompatible', async () => { const initialSettings = await client.cluster.getSettings({ flat_settings: true }); expect(getClusterRoutingAllocations(initialSettings)).toBe(true); @@ -108,15 +112,14 @@ describe('unsupported_cluster_routing_allocation', () => { expect(getClusterRoutingAllocations(updatedSettings)).toBe(false); - // now try to start Kibana + // Start Kibana root = createKbnRoot(); await root.preboot(); await root.setup(); - await expect(root.start()).rejects.toThrowError( - /Unable to complete saved object migrations for the \[\.kibana\] index: \[unsupported_cluster_routing_allocation\] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue\. To proceed, please remove the cluster routing allocation settings with PUT \/_cluster\/settings {\"transient\": {\"cluster\.routing\.allocation\.enable\": null}, \"persistent\": {\"cluster\.routing\.allocation\.enable\": null}}\. Refer to https:\/\/www.elastic.co\/guide\/en\/kibana\/master\/resolve-migrations-failures.html#routing-allocation-disabled for more information on how to resolve the issue\./ - ); + root.start(); + // Wait for the INIT -> INIT action retry await retryAsync( async () => { const logFileContent = await fs.readFile(logFilePath, 'utf-8'); @@ -124,32 +127,38 @@ describe('unsupported_cluster_routing_allocation', () => { .split('\n') .filter(Boolean) .map((str) => JSON5.parse(str)) as LogRecord[]; + + // Wait for logs of the second failed attempt to be sure we're correctly incrementing retries expect( records.find((rec) => - /^Unable to complete saved object migrations for the \[\.kibana.*\] index: \[unsupported_cluster_routing_allocation\] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue\./.test( - rec.message + rec.message.includes( + `Action failed with '[incompatible_cluster_routing_allocation] Incompatible Elasticsearch cluster settings detected. Remove the persistent and transient Elasticsearch cluster setting 'cluster.routing.allocation.enable' or set it to a value of 'all' to allow migrations to proceed. Refer to https://www.elastic.co/guide/en/kibana/master/resolve-migrations-failures.html#routing-allocation-disabled for more information on how to resolve the issue.'. Retrying attempt 2 in 4 seconds.` ) ) ).toBeDefined(); }, - { retryAttempts: 10, retryDelayMs: 200 } + { retryAttempts: 20, retryDelayMs: 500 } ); - }); - it('fails with a descriptive message when persistent replica allocation is set to "primaries"', async () => { - await updateRoutingAllocations(client, 'persistent', 'primaries'); + // Reset the cluster routing allocation settings + await updateRoutingAllocations(client, 'persistent', null); - const updatedSettings = await client.cluster.getSettings({ flat_settings: true }); - - expect(getClusterRoutingAllocations(updatedSettings)).toBe(false); - - // now try to start Kibana - root = createKbnRoot(); - await root.preboot(); - await root.setup(); + // Wait for migrations to succeed + await retryAsync( + async () => { + const logFileContent = await fs.readFile(logFilePath, 'utf-8'); + const records = logFileContent + .split('\n') + .filter(Boolean) + .map((str) => JSON5.parse(str)) as LogRecord[]; - await expect(root.start()).rejects.toThrowError( - /Unable to complete saved object migrations for the \[\.kibana\] index: \[unsupported_cluster_routing_allocation\] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue\. To proceed, please remove the cluster routing allocation settings with PUT \/_cluster\/settings {\"transient\": {\"cluster\.routing\.allocation\.enable\": null}, \"persistent\": {\"cluster\.routing\.allocation\.enable\": null}}\. Refer to https:\/\/www.elastic.co\/guide\/en\/kibana\/master\/resolve-migrations-failures.html#routing-allocation-disabled for more information on how to resolve the issue\./ + expect( + records.find((rec) => rec.message.includes('MARK_VERSION_INDEX_READY -> DONE')) + ).toBeDefined(); + }, + { retryAttempts: 100, retryDelayMs: 500 } ); + + await root.shutdown(); }); }); diff --git a/src/core/server/saved_objects/migrations/integration_tests/outdated_docs.test.ts b/src/core/server/saved_objects/migrations/integration_tests/outdated_docs.test.ts index c62a764aea6531f..bc1f41b542380f3 100644 --- a/src/core/server/saved_objects/migrations/integration_tests/outdated_docs.test.ts +++ b/src/core/server/saved_objects/migrations/integration_tests/outdated_docs.test.ts @@ -41,7 +41,7 @@ describe('migration v2', () => { await new Promise((resolve) => setTimeout(resolve, 10000)); }); - it.skip('migrates the documents to the highest version', async () => { + it('migrates the documents to the highest version', async () => { const migratedIndex = `.kibana_${pkg.version}_001`; const { startES } = kbnTestServer.createTestServers({ adjustTimeout: (t: number) => jest.setTimeout(t), @@ -86,7 +86,7 @@ describe('migration v2', () => { expect(migratedDocs.length).toBe(1); const [doc] = migratedDocs; expect(doc._source.migrationVersion.foo).toBe('7.14.0'); - expect(doc._source.coreMigrationVersion).toBe('8.0.0'); + expect(doc._source.coreMigrationVersion).toBe(pkg.version); }); }); diff --git a/src/core/server/saved_objects/migrations/model/extract_errors.test.ts b/src/core/server/saved_objects/migrations/model/extract_errors.test.ts index e434a5001a6aec5..ea6b312c2053db5 100644 --- a/src/core/server/saved_objects/migrations/model/extract_errors.test.ts +++ b/src/core/server/saved_objects/migrations/model/extract_errors.test.ts @@ -8,7 +8,6 @@ import { extractUnknownDocFailureReason, - fatalReasonClusterRoutingAllocationUnsupported, fatalReasonDocumentExceedsMaxBatchSizeBytes, } from './extract_errors'; @@ -55,18 +54,3 @@ describe('fatalReasonDocumentExceedsMaxBatchSizeBytes', () => { ); }); }); - -describe('fatalReasonClusterRoutingAllocationUnsupported', () => { - it('generates the correct error message', () => { - const errorMessages = fatalReasonClusterRoutingAllocationUnsupported({ - errorMessage: '[some-error] message', - docSectionLink: 'linkToDocsSection', - }); - expect(errorMessages.fatalReason).toMatchInlineSnapshot( - `"[some-error] message To proceed, please remove the cluster routing allocation settings with PUT /_cluster/settings {\\"transient\\": {\\"cluster.routing.allocation.enable\\": null}, \\"persistent\\": {\\"cluster.routing.allocation.enable\\": null}}. Refer to linkToDocsSection for more information on how to resolve the issue."` - ); - expect(errorMessages.logsErrorMessage).toMatchInlineSnapshot( - `"[some-error] message Ensure that the persistent and transient Elasticsearch configuration option 'cluster.routing.allocation.enable' is not set or set it to a value of 'all'. Refer to linkToDocsSection for more information on how to resolve the issue."` - ); - }); -}); diff --git a/src/core/server/saved_objects/migrations/model/extract_errors.ts b/src/core/server/saved_objects/migrations/model/extract_errors.ts index f41009ab2127c35..c529e1b1da2690e 100644 --- a/src/core/server/saved_objects/migrations/model/extract_errors.ts +++ b/src/core/server/saved_objects/migrations/model/extract_errors.ts @@ -65,18 +65,3 @@ export const fatalReasonDocumentExceedsMaxBatchSizeBytes = ({ maxBatchSizeBytes: number; }) => `The document with _id "${_id}" is ${docSizeBytes} bytes which exceeds the configured maximum batch size of ${maxBatchSizeBytes} bytes. To proceed, please increase the 'migrations.maxBatchSizeBytes' Kibana configuration option and ensure that the Elasticsearch 'http.max_content_length' configuration option is set to an equal or larger value.`; - -/** - * Constructs migration failure message and logs message strings when an unsupported cluster routing allocation is configured. - * The full errorMessage is "[unsupported_cluster_routing_allocation] The elasticsearch cluster has cluster routing allocation incorrectly set for migrations to continue." - */ -export const fatalReasonClusterRoutingAllocationUnsupported = ({ - errorMessage, - docSectionLink, -}: { - errorMessage: string; - docSectionLink: string; -}) => ({ - fatalReason: `${errorMessage} To proceed, please remove the cluster routing allocation settings with PUT /_cluster/settings {"transient": {"cluster.routing.allocation.enable": null}, "persistent": {"cluster.routing.allocation.enable": null}}. Refer to ${docSectionLink} for more information on how to resolve the issue.`, - logsErrorMessage: `${errorMessage} Ensure that the persistent and transient Elasticsearch configuration option 'cluster.routing.allocation.enable' is not set or set it to a value of 'all'. Refer to ${docSectionLink} for more information on how to resolve the issue.`, -}); diff --git a/src/core/server/saved_objects/migrations/model/model.test.ts b/src/core/server/saved_objects/migrations/model/model.test.ts index e44995ac8d30e08..e46024fc729d733 100644 --- a/src/core/server/saved_objects/migrations/model/model.test.ts +++ b/src/core/server/saved_objects/migrations/model/model.test.ts @@ -282,17 +282,21 @@ describe('migrations v2 model', () => { expect(newState.retryCount).toEqual(0); expect(newState.retryDelay).toEqual(0); }); - test('INIT -> FATAL when cluster routing allocation is not enabled', () => { + test('INIT -> INIT when cluster routing allocation is incompatible', () => { const res: ResponseType<'INIT'> = Either.left({ - type: 'unsupported_cluster_routing_allocation', - message: '[unsupported_cluster_routing_allocation]', + type: 'incompatible_cluster_routing_allocation', }); const newState = model(initState, res) as FatalState; - expect(newState.controlState).toEqual('FATAL'); - expect(newState.reason).toMatchInlineSnapshot( - `"[unsupported_cluster_routing_allocation] To proceed, please remove the cluster routing allocation settings with PUT /_cluster/settings {\\"transient\\": {\\"cluster.routing.allocation.enable\\": null}, \\"persistent\\": {\\"cluster.routing.allocation.enable\\": null}}. Refer to routingAllocationDisabled for more information on how to resolve the issue."` - ); + expect(newState.controlState).toEqual('INIT'); + expect(newState.retryCount).toEqual(1); + expect(newState.retryDelay).toEqual(2000); + expect(newState.logs[0]).toMatchInlineSnapshot(` + Object { + "level": "error", + "message": "Action failed with '[incompatible_cluster_routing_allocation] Incompatible Elasticsearch cluster settings detected. Remove the persistent and transient Elasticsearch cluster setting 'cluster.routing.allocation.enable' or set it to a value of 'all' to allow migrations to proceed. Refer to routingAllocationDisabled for more information on how to resolve the issue.'. Retrying attempt 1 in 2 seconds.", + } + `); }); test("INIT -> FATAL when .kibana points to newer version's index", () => { const res: ResponseType<'INIT'> = Either.right({ @@ -575,6 +579,12 @@ describe('migrations v2 model', () => { expect(newState.controlState).toEqual('LEGACY_CREATE_REINDEX_TARGET'); expect(newState.retryCount).toEqual(1); expect(newState.retryDelay).toEqual(2000); + expect(newState.logs[0]).toMatchInlineSnapshot(` + Object { + "level": "error", + "message": "Action failed with '[index_not_yellow_timeout] Timeout waiting for ... Refer to repeatedTimeoutRequests for information on how to resolve the issue.'. Retrying attempt 1 in 2 seconds.", + } + `); }); test('LEGACY_CREATE_REINDEX_TARGET -> LEGACY_REINDEX resets retry count and retry delay if action succeeds', () => { const res: ResponseType<'LEGACY_CREATE_REINDEX_TARGET'> = @@ -743,6 +753,12 @@ describe('migrations v2 model', () => { expect(newState.controlState).toEqual('WAIT_FOR_YELLOW_SOURCE'); expect(newState.retryCount).toEqual(1); expect(newState.retryDelay).toEqual(2000); + expect(newState.logs[0]).toMatchInlineSnapshot(` + Object { + "level": "error", + "message": "Action failed with '[index_not_yellow_timeout] Timeout waiting for ... Refer to repeatedTimeoutRequests for information on how to resolve the issue.'. Retrying attempt 1 in 2 seconds.", + } + `); }); test('WAIT_FOR_YELLOW_SOURCE -> CHECK_UNKNOWN_DOCUMENTS resets retry count and delay if action succeeds', () => { @@ -962,6 +978,12 @@ describe('migrations v2 model', () => { expect(newState.controlState).toEqual('CREATE_REINDEX_TEMP'); expect(newState.retryCount).toEqual(1); expect(newState.retryDelay).toEqual(2000); + expect(newState.logs[0]).toMatchInlineSnapshot(` + Object { + "level": "error", + "message": "Action failed with '[index_not_yellow_timeout] Timeout waiting for ... Refer to repeatedTimeoutRequests for information on how to resolve the issue.'. Retrying attempt 1 in 2 seconds.", + } + `); }); it('CREATE_REINDEX_TEMP -> REINDEX_SOURCE_TO_TEMP_OPEN_PIT resets retry count if action succeeds', () => { const res: ResponseType<'CREATE_REINDEX_TEMP'> = Either.right('create_index_succeeded'); @@ -1296,6 +1318,12 @@ describe('migrations v2 model', () => { expect(newState.controlState).toEqual('CLONE_TEMP_TO_TARGET'); expect(newState.retryCount).toEqual(1); expect(newState.retryDelay).toEqual(2000); + expect(newState.logs[0]).toMatchInlineSnapshot(` + Object { + "level": "error", + "message": "Action failed with '[index_not_yellow_timeout] Timeout waiting for ... Refer to repeatedTimeoutRequests for information on how to resolve the issue.'. Retrying attempt 1 in 2 seconds.", + } + `); }); it('CREATE_NEW_TARGET -> MARK_VERSION_INDEX_READY resets the retry count and delay', () => { const res: ResponseType<'CLONE_TEMP_TO_TARGET'> = Either.right({ diff --git a/src/core/server/saved_objects/migrations/model/model.ts b/src/core/server/saved_objects/migrations/model/model.ts index cff23f0eeda65fb..cbd194172018391 100644 --- a/src/core/server/saved_objects/migrations/model/model.ts +++ b/src/core/server/saved_objects/migrations/model/model.ts @@ -25,7 +25,6 @@ import { extractTransformFailuresReason, extractUnknownDocFailureReason, fatalReasonDocumentExceedsMaxBatchSizeBytes, - fatalReasonClusterRoutingAllocationUnsupported, } from './extract_errors'; import type { ExcludeRetryableEsError } from './types'; import { @@ -67,23 +66,9 @@ export const model = (currentState: State, resW: ResponseType): if (Either.isLeft(res)) { const left = res.left; - if (isLeftTypeof(left, 'unsupported_cluster_routing_allocation')) { - const initErrorMessages = fatalReasonClusterRoutingAllocationUnsupported({ - errorMessage: left.message, - docSectionLink: stateP.migrationDocLinks.routingAllocationDisabled, - }); - return { - ...stateP, - controlState: 'FATAL', - reason: initErrorMessages.fatalReason, - logs: [ - ...stateP.logs, - { - level: 'error', - message: initErrorMessages.logsErrorMessage, - }, - ], - }; + if (isLeftTypeof(left, 'incompatible_cluster_routing_allocation')) { + const retryErrorMessage = `[${left.type}] Incompatible Elasticsearch cluster settings detected. Remove the persistent and transient Elasticsearch cluster setting 'cluster.routing.allocation.enable' or set it to a value of 'all' to allow migrations to proceed. Refer to ${stateP.migrationDocLinks.routingAllocationDisabled} for more information on how to resolve the issue.`; + return delayRetryState(stateP, retryErrorMessage, stateP.retryAttempts); } else { return throwBadResponse(stateP, left); } diff --git a/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts index 1a47ba71962537c..048fb72af67bebd 100644 --- a/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts +++ b/src/core/server/saved_objects/service/lib/aggregations/aggs_types/bucket_aggs.ts @@ -19,6 +19,7 @@ import { sortOrderSchema } from './common_schemas'; * - nested * - reverse_nested * - terms + * - multi_terms * * Not fully supported: * - filter @@ -37,7 +38,6 @@ import { sortOrderSchema } from './common_schemas'; * - global * - ip_range * - missing - * - multi_terms * - parent * - range * - rare_terms @@ -63,6 +63,36 @@ const boolSchema = s.object({ }), }); +const orderSchema = s.oneOf([ + sortOrderSchema, + s.recordOf(s.string(), sortOrderSchema), + s.arrayOf(s.recordOf(s.string(), sortOrderSchema)), +]); + +const termsSchema = s.object({ + field: s.maybe(s.string()), + collect_mode: s.maybe(s.string()), + exclude: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])), + include: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])), + execution_hint: s.maybe(s.string()), + missing: s.maybe(s.number()), + min_doc_count: s.maybe(s.number({ min: 1 })), + size: s.maybe(s.number()), + show_term_doc_count_error: s.maybe(s.boolean()), + order: s.maybe(orderSchema), +}); + +const multiTermsSchema = s.object({ + terms: s.arrayOf(termsSchema), + size: s.maybe(s.number()), + shard_size: s.maybe(s.number()), + show_term_doc_count_error: s.maybe(s.boolean()), + min_doc_count: s.maybe(s.number()), + shard_min_doc_count: s.maybe(s.number()), + collect_mode: s.maybe(s.oneOf([s.literal('depth_first'), s.literal('breadth_first')])), + order: s.maybe(s.recordOf(s.string(), orderSchema)), +}); + export const bucketAggsSchemas: Record = { date_range: s.object({ field: s.string(), @@ -104,22 +134,6 @@ export const bucketAggsSchemas: Record = { reverse_nested: s.object({ path: s.maybe(s.string()), }), - terms: s.object({ - field: s.maybe(s.string()), - collect_mode: s.maybe(s.string()), - exclude: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])), - include: s.maybe(s.oneOf([s.string(), s.arrayOf(s.string())])), - execution_hint: s.maybe(s.string()), - missing: s.maybe(s.number()), - min_doc_count: s.maybe(s.number({ min: 1 })), - size: s.maybe(s.number()), - show_term_doc_count_error: s.maybe(s.boolean()), - order: s.maybe( - s.oneOf([ - sortOrderSchema, - s.recordOf(s.string(), sortOrderSchema), - s.arrayOf(s.recordOf(s.string(), sortOrderSchema)), - ]) - ), - }), + multi_terms: multiTermsSchema, + terms: termsSchema, }; diff --git a/src/core/server/saved_objects/service/lib/aggregations/validation.test.ts b/src/core/server/saved_objects/service/lib/aggregations/validation.test.ts index 0296dd25b56ee6a..db50ab2b45d653a 100644 --- a/src/core/server/saved_objects/service/lib/aggregations/validation.test.ts +++ b/src/core/server/saved_objects/service/lib/aggregations/validation.test.ts @@ -94,6 +94,28 @@ describe('validateAndConvertAggregations', () => { }); }); + it('validates multi_terms aggregations', () => { + expect( + validateAndConvertAggregations( + ['foo'], + { + aggName: { + multi_terms: { + terms: [{ field: 'foo.attributes.description' }, { field: 'foo.attributes.bytes' }], + }, + }, + }, + mockMappings + ) + ).toEqual({ + aggName: { + multi_terms: { + terms: [{ field: 'foo.description' }, { field: 'foo.bytes' }], + }, + }, + }); + }); + it('validates a nested field in simple aggregations', () => { expect( validateAndConvertAggregations( diff --git a/src/core/server/saved_objects/service/lib/aggregations/validation.ts b/src/core/server/saved_objects/service/lib/aggregations/validation.ts index 445d6b6a7ce226d..76098d73306af76 100644 --- a/src/core/server/saved_objects/service/lib/aggregations/validation.ts +++ b/src/core/server/saved_objects/service/lib/aggregations/validation.ts @@ -8,7 +8,7 @@ import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { ObjectType } from '@kbn/config-schema'; -import { isPlainObject } from 'lodash'; +import { isPlainObject, isArray } from 'lodash'; import { IndexMapping } from '../../../mappings'; import { @@ -181,11 +181,17 @@ const recursiveRewrite = ( const nestedContext = childContext(context, key); const newKey = rewriteKey ? validateAndRewriteAttributePath(key, nestedContext) : key; - const newValue = rewriteValue - ? validateAndRewriteAttributePath(value, nestedContext) - : isPlainObject(value) - ? recursiveRewrite(value, nestedContext, [...parents, key]) - : value; + + let newValue = value; + if (rewriteValue) { + newValue = validateAndRewriteAttributePath(value, nestedContext); + } else if (isArray(value)) { + newValue = value.map((v) => + isPlainObject(v) ? recursiveRewrite(v, nestedContext, parents) : v + ); + } else if (isPlainObject(value)) { + newValue = recursiveRewrite(value, nestedContext, [...parents, key]); + } return { ...memo, diff --git a/src/core/server/server.ts b/src/core/server/server.ts index bc5048de45cba2c..234630734d437b7 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -56,10 +56,25 @@ import { config as executionContextConfig } from './execution_context'; import { PrebootCoreRouteHandlerContext } from './preboot_core_route_handler_context'; import { PrebootService } from './preboot'; import { DiscoveredPlugins } from './plugins'; -import { AnalyticsService } from './analytics'; +import { AnalyticsService, AnalyticsServiceSetup } from './analytics'; const coreId = Symbol('core'); const rootConfigPath = ''; +const KIBANA_STARTED_EVENT = 'kibana_started'; + +/** @internal */ +interface UptimePerStep { + start: number; + end: number; +} + +/** @internal */ +interface UptimeSteps { + constructor: UptimePerStep; + preboot: UptimePerStep; + setup: UptimePerStep; + start: UptimePerStep; +} export class Server { public readonly configService: ConfigService; @@ -94,11 +109,15 @@ export class Server { private discoveredPlugins?: DiscoveredPlugins; private readonly logger: LoggerFactory; + private readonly uptimePerStep: Partial = {}; + constructor( rawConfigProvider: RawConfigurationProvider, public readonly env: Env, private readonly loggingSystem: ILoggingSystem ) { + const constructorStartUptime = process.uptime(); + this.logger = this.loggingSystem.asLoggerFactory(); this.log = this.logger.get('server'); this.configService = new ConfigService(rawConfigProvider, env, this.logger); @@ -129,15 +148,18 @@ export class Server { this.savedObjectsStartPromise = new Promise((resolve) => { this.resolveSavedObjectsStartPromise = resolve; }); + + this.uptimePerStep.constructor = { start: constructorStartUptime, end: process.uptime() }; } public async preboot() { this.log.debug('prebooting server'); + const prebootStartUptime = process.uptime(); const prebootTransaction = apm.startTransaction('server-preboot', 'kibana-platform'); const analyticsPreboot = this.analytics.preboot(); - const environmentPreboot = await this.environment.preboot(); + const environmentPreboot = await this.environment.preboot({ analytics: analyticsPreboot }); // Discover any plugins before continuing. This allows other systems to utilize the plugin dependency graph. this.discoveredPlugins = await this.plugins.discover({ environment: environmentPreboot }); @@ -187,15 +209,19 @@ export class Server { this.coreApp.preboot(corePreboot, uiPlugins); prebootTransaction?.end(); + this.uptimePerStep.preboot = { start: prebootStartUptime, end: process.uptime() }; return corePreboot; } public async setup() { this.log.debug('setting up server'); + const setupStartUptime = process.uptime(); const setupTransaction = apm.startTransaction('server-setup', 'kibana-platform'); const analyticsSetup = this.analytics.setup(); + this.registerKibanaStartedEventType(analyticsSetup); + const environmentSetup = this.environment.setup(); // Configuration could have changed after preboot. @@ -223,6 +249,7 @@ export class Server { const capabilitiesSetup = this.capabilities.setup({ http: httpSetup }); const elasticsearchServiceSetup = await this.elasticsearch.setup({ + analytics: analyticsSetup, http: httpSetup, executionContext: executionContextSetup, }); @@ -249,6 +276,7 @@ export class Server { }); const statusSetup = await this.status.setup({ + analytics: analyticsSetup, elasticsearch: elasticsearchServiceSetup, pluginDependencies: pluginTree.asNames, savedObjects: savedObjectsSetup, @@ -259,6 +287,7 @@ export class Server { }); const renderingSetup = await this.rendering.setup({ + elasticsearch: elasticsearchServiceSetup, http: httpSetup, status: statusSetup, uiPlugins, @@ -299,11 +328,13 @@ export class Server { this.coreApp.setup(coreSetup, uiPlugins); setupTransaction?.end(); + this.uptimePerStep.setup = { start: setupStartUptime, end: process.uptime() }; return coreSetup; } public async start() { this.log.debug('starting server'); + const startStartUptime = process.uptime(); const startTransaction = apm.startTransaction('server-start', 'kibana-platform'); const analyticsStart = this.analytics.start(); @@ -352,6 +383,9 @@ export class Server { startTransaction?.end(); + this.uptimePerStep.start = { start: startStartUptime, end: process.uptime() }; + analyticsStart.reportEvent(KIBANA_STARTED_EVENT, { uptime_per_step: this.uptimePerStep }); + return this.coreStart; } @@ -405,4 +439,92 @@ export class Server { this.configService.setSchema(descriptor.path, descriptor.schema); } } + + private registerKibanaStartedEventType(analyticsSetup: AnalyticsServiceSetup) { + analyticsSetup.registerEventType<{ uptime_per_step: UptimeSteps }>({ + eventType: KIBANA_STARTED_EVENT, + schema: { + uptime_per_step: { + properties: { + constructor: { + properties: { + start: { + type: 'float', + _meta: { + description: + 'Number of seconds the Node.js process has been running until the constructor was called', + }, + }, + end: { + type: 'float', + _meta: { + description: + 'Number of seconds the Node.js process has been running until the constructor finished', + }, + }, + }, + }, + preboot: { + properties: { + start: { + type: 'float', + _meta: { + description: + 'Number of seconds the Node.js process has been running until `preboot` was called', + }, + }, + end: { + type: 'float', + _meta: { + description: + 'Number of seconds the Node.js process has been running until `preboot` finished', + }, + }, + }, + }, + setup: { + properties: { + start: { + type: 'float', + _meta: { + description: + 'Number of seconds the Node.js process has been running until `setup` was called', + }, + }, + end: { + type: 'float', + _meta: { + description: + 'Number of seconds the Node.js process has been running until `setup` finished', + }, + }, + }, + }, + start: { + properties: { + start: { + type: 'float', + _meta: { + description: + 'Number of seconds the Node.js process has been running until `start` was called', + }, + }, + end: { + type: 'float', + _meta: { + description: + 'Number of seconds the Node.js process has been running until `start` finished', + }, + }, + }, + }, + }, + _meta: { + description: + 'Number of seconds the Node.js process has been running until each phase of the server execution is called and finished.', + }, + }, + }, + }); + } } diff --git a/src/core/server/status/status_service.test.ts b/src/core/server/status/status_service.test.ts index 262667fddf26a3d..70181db9380ff6d 100644 --- a/src/core/server/status/status_service.test.ts +++ b/src/core/server/status/status_service.test.ts @@ -6,11 +6,16 @@ * Side Public License, v 1. */ -import { of, BehaviorSubject } from 'rxjs'; - -import { ServiceStatus, ServiceStatusLevels, CoreStatus } from './types'; +import { of, BehaviorSubject, firstValueFrom } from 'rxjs'; + +import { + ServiceStatus, + ServiceStatusLevels, + CoreStatus, + InternalStatusServiceSetup, +} from './types'; import { StatusService } from './status_service'; -import { first } from 'rxjs/operators'; +import { first, take, toArray } from 'rxjs/operators'; import { mockCoreContext } from '../core_context.mock'; import { ServiceStatusLevelSnapshotSerializer } from './test_utils'; import { environmentServiceMock } from '../environment/environment_service.mock'; @@ -19,6 +24,8 @@ import { mockRouter, RouterMock } from '../http/router/router.mock'; import { metricsServiceMock } from '../metrics/metrics_service.mock'; import { configServiceMock } from '../config/mocks'; import { coreUsageDataServiceMock } from '../core_usage_data/core_usage_data_service.mock'; +import { analyticsServiceMock } from '../analytics/analytics_service.mock'; +import { AnalyticsServiceSetup } from '..'; expect.addSnapshotSerializer(ServiceStatusLevelSnapshotSerializer); @@ -47,6 +54,7 @@ describe('StatusService', () => { type SetupDeps = Parameters[0]; const setupDeps = (overrides: Partial): SetupDeps => { return { + analytics: analyticsServiceMock.createAnalyticsServiceSetup(), elasticsearch: { status$: of(available), }, @@ -535,5 +543,50 @@ describe('StatusService', () => { ); }); }); + + describe('analytics', () => { + let analyticsMock: jest.Mocked; + let setup: InternalStatusServiceSetup; + + beforeEach(async () => { + analyticsMock = analyticsServiceMock.createAnalyticsServiceSetup(); + setup = await service.setup(setupDeps({ analytics: analyticsMock })); + }); + + test('registers a context provider', async () => { + expect(analyticsMock.registerContextProvider).toHaveBeenCalledTimes(1); + const { context$ } = analyticsMock.registerContextProvider.mock.calls[0][0]; + await expect(firstValueFrom(context$.pipe(take(2), toArray()))).resolves + .toMatchInlineSnapshot(` + Array [ + Object { + "overall_status_level": "initializing", + "overall_status_summary": "Kibana is starting up", + }, + Object { + "overall_status_level": "available", + "overall_status_summary": "All services are available", + }, + ] + `); + }); + + test('registers and reports an event', async () => { + expect(analyticsMock.registerEventType).toHaveBeenCalledTimes(1); + expect(analyticsMock.reportEvent).toHaveBeenCalledTimes(0); + // wait for an emission of overall$ + await firstValueFrom(setup.overall$); + expect(analyticsMock.reportEvent).toHaveBeenCalledTimes(1); + expect(analyticsMock.reportEvent.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "core-overall_status_changed", + Object { + "overall_status_level": "available", + "overall_status_summary": "All services are available", + }, + ] + `); + }); + }); }); }); diff --git a/src/core/server/status/status_service.ts b/src/core/server/status/status_service.ts index 6c8f8716c036ea7..a3dc0335c88af42 100644 --- a/src/core/server/status/status_service.ts +++ b/src/core/server/status/status_service.ts @@ -6,10 +6,21 @@ * Side Public License, v 1. */ -import { Observable, combineLatest, Subscription, Subject, firstValueFrom } from 'rxjs'; -import { map, distinctUntilChanged, shareReplay, debounceTime } from 'rxjs/operators'; +import { + Observable, + combineLatest, + Subscription, + Subject, + firstValueFrom, + tap, + BehaviorSubject, +} from 'rxjs'; +import { map, distinctUntilChanged, shareReplay, debounceTime, takeUntil } from 'rxjs/operators'; import { isDeepStrictEqual } from 'util'; +import type { RootSchema } from '@kbn/analytics-client'; + +import { AnalyticsServiceSetup } from '../analytics'; import { CoreService } from '../../types'; import { CoreContext } from '../core_context'; import { Logger, LogMeta } from '../logging'; @@ -32,7 +43,13 @@ interface StatusLogMeta extends LogMeta { kibana: { status: ServiceStatus }; } +interface StatusAnalyticsPayload { + overall_status_level: string; + overall_status_summary: string; +} + export interface SetupDeps { + analytics: AnalyticsServiceSetup; elasticsearch: Pick; environment: InternalEnvironmentServiceSetup; pluginDependencies: ReadonlyMap; @@ -57,6 +74,7 @@ export class StatusService implements CoreService { } public async setup({ + analytics, elasticsearch, pluginDependencies, http, @@ -88,6 +106,8 @@ export class StatusService implements CoreService { shareReplay(1) ); + this.setupAnalyticsContextAndEvents(analytics); + const coreOverall$ = core$.pipe( // Prevent many emissions at once from dependency status resolution from making this too noisy debounceTime(25), @@ -192,4 +212,40 @@ export class StatusService implements CoreService { shareReplay(1) ); } + + private setupAnalyticsContextAndEvents(analytics: AnalyticsServiceSetup) { + // Set an initial "initializing" status, so we can attach it to early events. + const context$ = new BehaviorSubject({ + overall_status_level: 'initializing', + overall_status_summary: 'Kibana is starting up', + }); + + // The schema is the same for the context and the events. + const schema: RootSchema = { + overall_status_level: { + type: 'keyword', + _meta: { description: 'The current availability level of the service.' }, + }, + overall_status_summary: { + type: 'text', + _meta: { description: 'A high-level summary of the service status.' }, + }, + }; + + const overallStatusChangedEventName = 'core-overall_status_changed'; + + analytics.registerEventType({ eventType: overallStatusChangedEventName, schema }); + analytics.registerContextProvider({ name: 'status info', context$, schema }); + + this.overall$!.pipe( + takeUntil(this.stop$), + map(({ level, summary }) => ({ + overall_status_level: level.toString(), + overall_status_summary: summary, + })), + // Emit the event before spreading the status to the context. + // This way we see from the context the previous status and the current one. + tap((statusPayload) => analytics.reportEvent(overallStatusChangedEventName, statusPayload)) + ).subscribe(context$); + } } diff --git a/src/core/types/execution_context.ts b/src/core/types/execution_context.ts index d790b8d855fd4ef..d1e5cd10e5e914a 100644 --- a/src/core/types/execution_context.ts +++ b/src/core/types/execution_context.ts @@ -14,7 +14,7 @@ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type KibanaExecutionContext = { /** - * Kibana application initated an operation. + * Kibana application initiated an operation. * */ readonly type?: string; // 'visualization' | 'actions' | 'server' | ..; /** public name of an application or a user-facing feature */ diff --git a/src/dev/build/build_distributables.ts b/src/dev/build/build_distributables.ts index ad9c8323b769d85..0a3db5dc36d070e 100644 --- a/src/dev/build/build_distributables.ts +++ b/src/dev/build/build_distributables.ts @@ -80,10 +80,11 @@ export async function buildDistributables(log: ToolingLog, options: BuildOptions await run(Tasks.CreatePackageJson); await run(Tasks.InstallDependencies); await run(Tasks.GeneratePackagesOptimizedAssets); - await run(Tasks.CleanPackages); + await run(Tasks.DeleteBazelPackagesFromBuildRoot); await run(Tasks.CreateNoticeFile); await run(Tasks.UpdateLicenseFile); await run(Tasks.RemovePackageJsonDeps); + await run(Tasks.CleanPackageManagerRelatedFiles); await run(Tasks.CleanTypescript); await run(Tasks.CleanExtraFilesFromModules); await run(Tasks.CleanEmptyFolders); diff --git a/src/dev/build/lib/integration_tests/download.test.ts b/src/dev/build/lib/integration_tests/download.test.ts index 4c5b7025da098dc..7046a831d84f0ed 100644 --- a/src/dev/build/lib/integration_tests/download.test.ts +++ b/src/dev/build/lib/integration_tests/download.test.ts @@ -196,7 +196,7 @@ describe('downloadToDisk', () => { retryDelaySecMultiplier: 0.1, }); await expect(promise).rejects.toMatchInlineSnapshot( - `[Error: Request failed with status code 500]` + `[AxiosError: Request failed with status code 500]` ); expect(logWritter.messages).toMatchInlineSnapshot(` Array [ @@ -269,7 +269,7 @@ describe('downloadToString', () => { maxAttempts: 1, }); await expect(promise).rejects.toMatchInlineSnapshot( - `[Error: Request failed with status code 200]` + `[AxiosError: Request failed with status code 200]` ); expect(logWritter.messages).toMatchInlineSnapshot(` Array [ diff --git a/src/dev/build/lib/scan.ts b/src/dev/build/lib/scan.ts index 5ba7385792d7456..1c1a198d6d97da5 100644 --- a/src/dev/build/lib/scan.ts +++ b/src/dev/build/lib/scan.ts @@ -12,9 +12,6 @@ import { join } from 'path'; import * as Rx from 'rxjs'; import { map, mergeAll, mergeMap } from 'rxjs/operators'; -// @ts-ignore -import { assertAbsolute } from './fs'; - const getStat$ = Rx.bindNodeCallback<[string], [Fs.Stats]>(Fs.stat); const getReadDir$ = Rx.bindNodeCallback<[string], [string[]]>(Fs.readdir); diff --git a/src/dev/build/tasks/build_packages_task.ts b/src/dev/build/tasks/build_packages_task.ts index e30ffd082e250a7..62baf74559a2a7b 100644 --- a/src/dev/build/tasks/build_packages_task.ts +++ b/src/dev/build/tasks/build_packages_task.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import cpy from 'cpy'; import Path from 'path'; import { discoverBazelPackages } from '@kbn/bazel-packages'; @@ -53,9 +54,9 @@ export const BuildXpack: Task = { }); log.info('copying built x-pack into build dir'); - await scanCopy({ - source: config.resolveFromRepo('x-pack/build/plugin/kibana/x-pack'), - destination: build.resolvePath('x-pack'), + await cpy('**/{.,}*', build.resolvePath('x-pack'), { + cwd: config.resolveFromRepo('x-pack/build/plugin/kibana/x-pack'), + parents: true, }); }, }; diff --git a/src/dev/build/tasks/clean_tasks.ts b/src/dev/build/tasks/clean_tasks.ts index 19747ce72b5a65a..c794ca277f77f99 100644 --- a/src/dev/build/tasks/clean_tasks.ts +++ b/src/dev/build/tasks/clean_tasks.ts @@ -7,7 +7,7 @@ */ import minimatch from 'minimatch'; - +import { discoverBazelPackages } from '@kbn/bazel-packages'; import { deleteAll, deleteEmptyFolders, scanDelete, Task, GlobalTask } from '../lib'; export const Clean: GlobalTask = { @@ -26,14 +26,11 @@ export const Clean: GlobalTask = { }, }; -export const CleanPackages: Task = { - description: 'Cleaning source for packages that are now installed in node_modules', +export const CleanPackageManagerRelatedFiles: Task = { + description: 'Cleaning package manager related files from the build folder', async run(config, log, build) { - await deleteAll( - [build.resolvePath('packages'), build.resolvePath('yarn.lock'), build.resolvePath('.npmrc')], - log - ); + await deleteAll([build.resolvePath('yarn.lock'), build.resolvePath('.npmrc')], log); }, }; @@ -200,3 +197,16 @@ export const CleanEmptyFolders: Task = { ]); }, }; + +export const DeleteBazelPackagesFromBuildRoot: Task = { + description: + 'Deleting bazel packages outputs from build folder root as they are now installed as node_modules', + + async run(config, log, build) { + const bazelPackagesOnBuildRoot = (await discoverBazelPackages()).map((pkg) => + build.resolvePath(pkg.normalizedRepoRelativeDir) + ); + + await deleteAll(bazelPackagesOnBuildRoot, log); + }, +}; diff --git a/src/dev/build/tasks/copy_source_task.ts b/src/dev/build/tasks/copy_source_task.ts index 141eafc57ae1fa6..9fc0827e8c2c623 100644 --- a/src/dev/build/tasks/copy_source_task.ts +++ b/src/dev/build/tasks/copy_source_task.ts @@ -6,8 +6,7 @@ * Side Public License, v 1. */ -import { getAllRepoRelativeBazelPackageDirs } from '@kbn/bazel-packages'; -import normalizePath from 'normalize-path'; +import { discoverBazelPackages } from '@kbn/bazel-packages'; import { copyAll, Task } from '../lib'; @@ -48,8 +47,8 @@ export const CopySource: Task = { 'tsconfig*.json', '.i18nrc.json', 'kibana.d.ts', - // explicitly ignore all package roots, even if they're not selected by previous patterns - ...getAllRepoRelativeBazelPackageDirs().map((dir) => `!${normalizePath(dir)}/**`), + // explicitly ignore all bazel package locations, even if they're not selected by previous patterns + ...(await discoverBazelPackages()).map((pkg) => `!${pkg.normalizedRepoRelativeDir}/**`), ], }); }, diff --git a/src/dev/build/tasks/os_packages/create_os_package_tasks.ts b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts index 88240429856d118..49967feb214d6fd 100644 --- a/src/dev/build/tasks/os_packages/create_os_package_tasks.ts +++ b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts @@ -65,16 +65,16 @@ export const CreateDockerUbuntu: Task = { async run(config, log, build) { await runDockerGenerator(config, log, build, { architecture: 'x64', + baseImage: 'ubuntu', context: false, image: true, - ubuntu: true, dockerBuildDate, }); await runDockerGenerator(config, log, build, { architecture: 'aarch64', + baseImage: 'ubuntu', context: false, image: true, - ubuntu: true, dockerBuildDate, }); }, @@ -86,8 +86,8 @@ export const CreateDockerUBI: Task = { async run(config, log, build) { await runDockerGenerator(config, log, build, { architecture: 'x64', + baseImage: 'ubi', context: false, - ubi: true, image: true, }); }, @@ -99,16 +99,16 @@ export const CreateDockerCloud: Task = { async run(config, log, build) { await runDockerGenerator(config, log, build, { architecture: 'x64', + baseImage: 'ubuntu', context: false, cloud: true, - ubuntu: true, image: true, }); await runDockerGenerator(config, log, build, { architecture: 'aarch64', + baseImage: 'ubuntu', context: false, cloud: true, - ubuntu: true, image: true, }); }, @@ -119,23 +119,25 @@ export const CreateDockerContexts: Task = { async run(config, log, build) { await runDockerGenerator(config, log, build, { - ubuntu: true, + baseImage: 'ubuntu', context: true, image: false, dockerBuildDate, }); await runDockerGenerator(config, log, build, { - ubi: true, + baseImage: 'ubi', context: true, image: false, }); await runDockerGenerator(config, log, build, { ironbank: true, + baseImage: 'none', context: true, image: false, }); await runDockerGenerator(config, log, build, { + baseImage: 'ubuntu', cloud: true, context: true, image: false, diff --git a/src/dev/build/tasks/os_packages/docker_generator/run.ts b/src/dev/build/tasks/os_packages/docker_generator/run.ts index 264c6e52db0eb5b..d8b604f00b46eca 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/run.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/run.ts @@ -29,22 +29,21 @@ export async function runDockerGenerator( build: Build, flags: { architecture?: string; + baseImage: 'none' | 'ubi' | 'ubuntu'; context: boolean; image: boolean; - ubi?: boolean; - ubuntu?: boolean; ironbank?: boolean; cloud?: boolean; dockerBuildDate?: string; } ) { - let baseOSImage = ''; - if (flags.ubuntu) baseOSImage = 'ubuntu:20.04'; - if (flags.ubi) baseOSImage = 'docker.elastic.co/ubi8/ubi-minimal:latest'; + let baseImageName = ''; + if (flags.baseImage === 'ubuntu') baseImageName = 'ubuntu:20.04'; + if (flags.baseImage === 'ubi') baseImageName = 'docker.elastic.co/ubi8/ubi-minimal:latest'; const ubiVersionTag = 'ubi8'; let imageFlavor = ''; - if (flags.ubi) imageFlavor += `-${ubiVersionTag}`; + if (flags.baseImage === 'ubi') imageFlavor += `-${ubiVersionTag}`; if (flags.ironbank) imageFlavor += '-ironbank'; if (flags.cloud) imageFlavor += '-cloud'; @@ -61,7 +60,6 @@ export async function runDockerGenerator( const artifactsDir = config.resolveFromTarget('.'); const beatsDir = config.resolveFromRepo('.beats'); const dockerBuildDate = flags.dockerBuildDate || new Date().toISOString(); - // That would produce oss, default and default-ubi7 const dockerBuildDir = config.resolveFromRepo('build', 'kibana-docker', `default${imageFlavor}`); const imageArchitecture = flags.architecture === 'aarch64' ? '-aarch64' : ''; const dockerTargetFilename = config.resolveFromTarget( @@ -93,10 +91,9 @@ export async function runDockerGenerator( dockerPush, dockerTagQualifier, dockerCrossCompile, - baseOSImage, + baseImageName, dockerBuildDate, - ubi: flags.ubi, - ubuntu: flags.ubuntu, + baseImage: flags.baseImage, cloud: flags.cloud, metricbeatTarball, filebeatTarball, diff --git a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts index 35977d47aaaa72b..32a551820a05b57 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts @@ -20,12 +20,11 @@ export interface TemplateContext { imageTag: string; dockerBuildDir: string; dockerTargetFilename: string; - baseOSImage: string; dockerBuildDate: string; usePublicArtifact?: boolean; publicArtifactSubdomain: string; - ubi?: boolean; - ubuntu?: boolean; + baseImage: 'none' | 'ubi' | 'ubuntu'; + baseImageName: string; cloud?: boolean; metricbeatTarball?: string; filebeatTarball?: string; diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile b/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile index 95f6a56ef68cb2c..d171c48662cf6ec 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile @@ -9,7 +9,7 @@ # Build stage 0 `builder`: # Extract Kibana artifact ################################################################################ -FROM {{{baseOSImage}}} AS builder +FROM {{{baseImageName}}} AS builder {{#ubi}} RUN {{packageManager}} install -y findutils tar gzip @@ -54,7 +54,7 @@ RUN mkdir -p /opt/filebeat /opt/metricbeat && \ # Copy kibana from stage 0 # Add entrypoint ################################################################################ -FROM {{{baseOSImage}}} +FROM {{{baseImageName}}} EXPOSE 5601 {{#ubi}} diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts index 316428d46a957ff..472e64e849b5810 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/build_docker_sh.template.ts @@ -18,7 +18,7 @@ function generator({ dockerCrossCompile, version, dockerTargetFilename, - baseOSImage, + baseImageName, architecture, }: TemplateContext) { const dockerTargetName = `${imageTag}${imageFlavor}:${version}${ @@ -61,7 +61,7 @@ function generator({ done } - retry_docker_pull ${baseOSImage} + retry_docker_pull ${baseImageName} echo "Building: kibana${imageFlavor}-docker"; \\ ${dockerBuild} diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts b/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts index 94068f2b64b1254..63b04ed6f70b038 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/dockerfile.template.ts @@ -16,7 +16,9 @@ function generator(options: TemplateContext) { const dir = options.ironbank ? 'ironbank' : 'base'; const template = readFileSync(resolve(__dirname, dir, './Dockerfile')); return Mustache.render(template.toString(), { - packageManager: options.ubi ? 'microdnf' : 'apt-get', + packageManager: options.baseImage === 'ubi' ? 'microdnf' : 'apt-get', + ubi: options.baseImage === 'ubi', + ubuntu: options.baseImage === 'ubuntu', ...options, }); } diff --git a/src/dev/build/tasks/transpile_babel_task.ts b/src/dev/build/tasks/transpile_babel_task.ts index 37f63d31415e994..ee7d1e19de43a4b 100644 --- a/src/dev/build/tasks/transpile_babel_task.ts +++ b/src/dev/build/tasks/transpile_babel_task.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { discoverBazelPackages } from '@kbn/bazel-packages'; import { pipeline } from 'stream'; import { promisify } from 'util'; @@ -24,10 +25,10 @@ const transpileWithBabel = async (srcGlobs: string[], build: Build, preset: stri vfs.src( srcGlobs.concat([ '!**/*.d.ts', - '!packages/**', '!**/node_modules/**', '!**/bower_components/**', '!**/__tests__/**', + ...(await discoverBazelPackages()).map((pkg) => `!${pkg.normalizedRepoRelativeDir}/**`), ]), { cwd: buildRoot, diff --git a/src/dev/i18n/utils/verify_icu_message.ts b/src/dev/i18n/utils/verify_icu_message.ts index 8cfc80146d0f176..5243dfb4396b8eb 100644 --- a/src/dev/i18n/utils/verify_icu_message.ts +++ b/src/dev/i18n/utils/verify_icu_message.ts @@ -9,7 +9,7 @@ // @ts-ignore import parser from 'intl-messageformat-parser'; // @ts-ignore -import { createParserErrorMessage, traverseNodes } from './utils'; +import { createParserErrorMessage } from './utils'; import { SelectFormatNode } from './intl_types'; export function checkEnglishOnly(message: string) { diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index bd5fd75e30998e5..0ccab6fcf1b249b 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -77,6 +77,6 @@ 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/eui@55.0.1': ['SSPL-1.0 OR 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/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index 8aa2d6f1cfe5511..ced601d0f39818e 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -146,10 +146,10 @@ export const TEMPORARILY_IGNORED_PATHS = [ 'x-pack/plugins/monitoring/public/icons/health-green.svg', 'x-pack/plugins/monitoring/public/icons/health-red.svg', 'x-pack/plugins/monitoring/public/icons/health-yellow.svg', - 'x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/noto/NotoSansCJKtc-Medium.ttf', - 'x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/noto/NotoSansCJKtc-Regular.ttf', - 'x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/roboto/Roboto-Italic.ttf', - 'x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/roboto/Roboto-Medium.ttf', - 'x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/roboto/Roboto-Regular.ttf', - 'x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/img/logo-grey.png', + 'x-pack/plugins/screenshotting/server/assets/fonts/noto/NotoSansCJKtc-Medium.ttf', + 'x-pack/plugins/screenshotting/server/assets/fonts/noto/NotoSansCJKtc-Regular.ttf', + 'x-pack/plugins/screenshotting/server/assets/fonts/roboto/Roboto-Italic.ttf', + 'x-pack/plugins/screenshotting/server/assets/fonts/roboto/Roboto-Medium.ttf', + 'x-pack/plugins/screenshotting/server/assets/fonts/roboto/Roboto-Regular.ttf', + 'x-pack/plugins/screenshotting/server/assets/img/logo-grey.png', ]; diff --git a/src/dev/prs/kibana_qa_pr_list.json b/src/dev/prs/kibana_qa_pr_list.json index e8d27ba9f2f0a44..503c95d2e7c0fce 100644 --- a/src/dev/prs/kibana_qa_pr_list.json +++ b/src/dev/prs/kibana_qa_pr_list.json @@ -89,8 +89,10 @@ "Feature:Observability Landing - Milestone 1", "Feature:Osquery", "Feature:Transforms", +"Feature:Unified Integrations", "Synthetics", "Team: AWL: Platform", +"Team: AWP: Visualization", "Team: Actionable Observability", "Team: CTI", "Team: SecuritySolution", @@ -109,6 +111,7 @@ "Team:Infra Monitoring UI", "Team:Ingest Management", "Team:Observability", +"Team:Unified observability", "Team:Onboarding and Lifecycle Mgt", "Team:Operations", "Team:QA", diff --git a/src/dev/run_precommit_hook.js b/src/dev/run_precommit_hook.js index dfa3a94426bb249..a86bb5c7dabcc13 100644 --- a/src/dev/run_precommit_hook.js +++ b/src/dev/run_precommit_hook.js @@ -16,6 +16,8 @@ import { getFilesForCommit, checkFileCasing } from './precommit_hook'; run( async ({ log, flags }) => { + process.env.IS_KIBANA_PRECOMIT_HOOK = 'true'; + const files = await getFilesForCommit(flags.ref); const errors = []; @@ -47,7 +49,7 @@ run( fix: flags.fix, }); - if (flags.fix) { + if (flags.fix && flags.stage) { const simpleGit = new SimpleGit(REPO_ROOT); await simpleGit.add(filesToLint); } @@ -66,16 +68,18 @@ run( Run checks on files that are staged for commit by default `, flags: { - boolean: ['fix'], + boolean: ['fix', 'stage'], string: ['max-files', 'ref'], default: { fix: false, + stage: true, }, help: ` - --fix Execute eslint in --fix mode - --max-files Max files number to check against. If exceeded the script will skip the execution - --ref Run checks against any git ref files (example HEAD or ) instead of running against staged ones - `, + --fix Execute eslint in --fix mode + --max-files Max files number to check against. If exceeded the script will skip the execution + --ref Run checks against any git ref files (example HEAD or ) instead of running against staged ones + --no-stage By default when using --fix the changes are staged, use --no-stage to disable that behavior + `, }, } ); diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 4167719d3bb3148..45b8aad7df8cfdd 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -8,6 +8,7 @@ // Please also add new aliases to test/scripts/jenkins_storybook.sh export const storybookAliases = { + unified_search: 'src/plugins/unified_search/.storybook', coloring: 'packages/kbn-coloring/.storybook', apm: 'x-pack/plugins/apm/.storybook', canvas: 'x-pack/plugins/canvas/storybook', diff --git a/src/dev/typescript/projects.ts b/src/dev/typescript/projects.ts index 6d9b372069b225b..848ca09a86671fd 100644 --- a/src/dev/typescript/projects.ts +++ b/src/dev/typescript/projects.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import glob from 'glob'; +import globby from 'globby'; import Path from 'path'; import { REPO_ROOT } from '@kbn/utils'; import { BAZEL_PACKAGE_DIRS } from '@kbn/bazel-packages'; @@ -23,11 +23,8 @@ const createProject = (rootRelativePath: string, options: ProjectOptions = {}) = cache: PROJECT_CACHE, }); -const findProjects = (pattern: string) => - // NOTE: using glob.sync rather than glob-all or globby - // because it takes less than 10 ms, while the other modules - // both took closer to 1000ms. - glob.sync(pattern, { cwd: REPO_ROOT }).map((path) => createProject(path)); +const findProjects = (patterns: string[]) => + globby.sync(patterns, { cwd: REPO_ROOT }).map((path) => createProject(path)); export const PROJECTS = [ createProject('tsconfig.json'), @@ -73,16 +70,18 @@ export const PROJECTS = [ disableTypeCheck: true, }), - ...findProjects('src/plugins/*/tsconfig.json'), - ...findProjects('src/plugins/chart_expressions/*/tsconfig.json'), - ...findProjects('src/plugins/vis_types/*/tsconfig.json'), - ...findProjects('x-pack/plugins/*/tsconfig.json'), - ...findProjects('examples/*/tsconfig.json'), - ...findProjects('x-pack/examples/*/tsconfig.json'), - ...findProjects('test/plugin_functional/plugins/*/tsconfig.json'), - ...findProjects('test/interpreter_functional/plugins/*/tsconfig.json'), - ...findProjects('test/server_integration/__fixtures__/plugins/*/tsconfig.json'), - ...findProjects('packages/kbn-type-summarizer/tests/tsconfig.json'), - - ...BAZEL_PACKAGE_DIRS.flatMap((dir) => findProjects(`${dir}/*/tsconfig.json`)), + // Glob patterns to be all search at once + ...findProjects([ + 'src/plugins/*/tsconfig.json', + 'src/plugins/chart_expressions/*/tsconfig.json', + 'src/plugins/vis_types/*/tsconfig.json', + 'x-pack/plugins/*/tsconfig.json', + 'examples/*/tsconfig.json', + 'x-pack/examples/*/tsconfig.json', + 'test/plugin_functional/plugins/*/tsconfig.json', + 'test/interpreter_functional/plugins/*/tsconfig.json', + 'test/server_integration/__fixtures__/plugins/*/tsconfig.json', + 'packages/kbn-type-summarizer/tests/tsconfig.json', + ...BAZEL_PACKAGE_DIRS.map((dir) => `${dir}/*/tsconfig.json`), + ]), ]; diff --git a/src/plugins/advanced_settings/public/management_app/components/field/field.test.tsx b/src/plugins/advanced_settings/public/management_app/components/field/field.test.tsx index 555df3c2c5c11ef..0e67d787be14480 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/field.test.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/field/field.test.tsx @@ -388,7 +388,7 @@ describe('Field', () => { const updated = wrapper.update(); findTestSubject(updated, `advancedSetting-resetField-${setting.name}`).simulate('click'); expect(handleChange).toBeCalledWith(setting.name, { - value: getEditableValue(setting.type, setting.defVal), + value: getEditableValue(setting.type, setting.defVal, setting.defVal), changeImage: true, }); }); diff --git a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx index fd4674a7caf6eb2..56673cda1a9536c 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/field.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/field/field.tsx @@ -100,7 +100,7 @@ export class Field extends PureComponent { if (type === 'image') { this.cancelChangeImage(); return this.handleChange({ - value: getEditableValue(type, defVal), + value: getEditableValue(type, defVal, defVal), changeImage: true, }); } diff --git a/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts b/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts index fbdd962a9529847..14fccf09d83b021 100644 --- a/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts +++ b/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts @@ -196,6 +196,33 @@ describe('createStreamingBatchedFunction()', () => { }); }); + test("doesn't send batch request if all items have been aborted", async () => { + const { fetchStreaming } = setup(); + const fn = createStreamingBatchedFunction({ + url: '/test', + fetchStreaming, + maxItemAge: 5, + flushOnMaxItems: 3, + getIsCompressionDisabled: () => true, + }); + + const abortController = new AbortController(); + abortController.abort(); + + expect.assertions(3); + const req1 = fn({ foo: 'bar' }, abortController.signal).catch((e) => + expect(e).toBeInstanceOf(AbortError) + ); + const req2 = fn({ baz: 'quix' }, abortController.signal).catch((e) => + expect(e).toBeInstanceOf(AbortError) + ); + + jest.advanceTimersByTime(6); + expect(fetchStreaming).not.toBeCalled(); + + await Promise.all([req1, req2]); + }); + test('sends POST request to correct endpoint with items in array batched sorted in call order', async () => { const { fetchStreaming } = setup(); const fn = createStreamingBatchedFunction({ diff --git a/src/plugins/bfetch/public/batching/create_streaming_batched_function.ts b/src/plugins/bfetch/public/batching/create_streaming_batched_function.ts index c1618c9d3bbc10d..b2e530a3a9bc105 100644 --- a/src/plugins/bfetch/public/batching/create_streaming_batched_function.ts +++ b/src/plugins/bfetch/public/batching/create_streaming_batched_function.ts @@ -88,6 +88,10 @@ export const createStreamingBatchedFunction = ( return !item.signal?.aborted; }); + if (items.length === 0) { + return; // all items have been aborted before a request has been sent + } + const donePromises: Array> = items.map((item) => { return new Promise((resolve) => { const { promise: abortPromise, cleanup } = item.signal diff --git a/src/plugins/bfetch/server/streaming/create_compressed_stream.ts b/src/plugins/bfetch/server/streaming/create_compressed_stream.ts index 5f7319ed154fc6e..b3c9c8db1269919 100644 --- a/src/plugins/bfetch/server/streaming/create_compressed_stream.ts +++ b/src/plugins/bfetch/server/streaming/create_compressed_stream.ts @@ -37,7 +37,7 @@ export const createCompressedStream = ( ): Stream => { const output = new PassThrough(); - const sub = results + results .pipe( concatMap((message: Response) => { const strMessage = JSON.stringify(message); @@ -50,7 +50,6 @@ export const createCompressedStream = ( }), finalize(() => { output.end(); - sub.unsubscribe(); }) ) .subscribe(); diff --git a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.test.ts b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.test.ts index ac8cb5c8b86a345..705516a9f1e969c 100644 --- a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.test.ts +++ b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.test.ts @@ -111,6 +111,7 @@ describe('interpreter/functions#gauge', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts index 48c7261e43016e6..70ecd25839d1948 100644 --- a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts +++ b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts @@ -194,6 +194,9 @@ export const gaugeFunction = (): GaugeExpressionFunctionDefinition => ({ } if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const logTable = prepareLogTable( data, [ diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.test.ts b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.test.ts index 13beee6b0f70151..d34442ca3f518c8 100644 --- a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.test.ts +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.test.ts @@ -69,6 +69,7 @@ describe('interpreter/functions#heatmap', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts index c440176962faf19..954c5acee715220 100644 --- a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_function.ts @@ -161,6 +161,9 @@ export const heatmapFunction = (): HeatmapExpressionFunctionDefinition => ({ validateAccessor(args.splitColumnAccessor, data.columns); if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const argsTable: Dimension[] = []; if (args.valueAccessor) { prepareHeatmapLogTable( diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts index 1135708db8c2204..79a356ddad934e9 100644 --- a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts @@ -8,6 +8,7 @@ import { Position } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; import type { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common'; +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; import { EXPRESSION_HEATMAP_LEGEND_NAME } from '../constants'; import { HeatmapLegendConfig, HeatmapLegendConfigResult } from '../types'; @@ -52,10 +53,19 @@ export const heatmapLegendConfig: ExpressionFunctionDefinition< }), }, legendSize: { - types: ['number'], + types: ['string'], + default: DEFAULT_LEGEND_SIZE, help: i18n.translate('expressionHeatmap.function.args.legendSize.help', { - defaultMessage: 'Specifies the legend size in pixels.', + defaultMessage: 'Specifies the legend size.', }), + options: [ + LegendSize.AUTO, + LegendSize.SMALL, + LegendSize.MEDIUM, + LegendSize.LARGE, + LegendSize.EXTRA_LARGE, + ], + strict: true, }, }, fn(input, args) { diff --git a/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts b/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts index d3e7444ad08f22a..19f63f9df98906a 100644 --- a/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts +++ b/src/plugins/chart_expressions/expression_heatmap/common/types/expression_functions.ts @@ -15,6 +15,7 @@ import { import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; import { CustomPaletteState } from '@kbn/charts-plugin/common'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; import { EXPRESSION_HEATMAP_NAME, EXPRESSION_HEATMAP_LEGEND_NAME, @@ -43,7 +44,7 @@ export interface HeatmapLegendConfig { * Exact legend width (vertical) or height (horizontal) * Limited to max of 70% of the chart container dimension Vertical legends limited to min of 30% of computed width */ - legendSize?: number; + legendSize?: LegendSize; } export type HeatmapLegendConfigResult = HeatmapLegendConfig & { diff --git a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx index 4f3e77b8f1d6e7c..19a57272116c826 100644 --- a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx +++ b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.test.tsx @@ -17,6 +17,7 @@ import { findTestSubject } from '@elastic/eui/lib/test'; import { act } from 'react-dom/test-utils'; import { HeatmapRenderProps, HeatmapArguments } from '../../common'; import HeatmapComponent from './heatmap_component'; +import { LegendSize } from '@kbn/visualizations-plugin/common'; jest.mock('@elastic/charts', () => { const original = jest.requireActual('@elastic/charts'); @@ -47,6 +48,7 @@ const args: HeatmapArguments = { isVisible: true, position: 'top', type: 'heatmap_legend', + legendSize: LegendSize.SMALL, }, gridConfig: { isCellLabelVisible: true, @@ -119,6 +121,33 @@ describe('HeatmapComponent', function () { expect(component.find(Settings).prop('legendPosition')).toEqual('top'); }); + it('sets correct legend sizes', () => { + const component = shallowWithIntl(); + expect(component.find(Settings).prop('legendSize')).toEqual(80); + + component.setProps({ + args: { + ...args, + legend: { + ...args.legend, + legendSize: LegendSize.AUTO, + }, + }, + }); + expect(component.find(Settings).prop('legendSize')).toBeUndefined(); + + component.setProps({ + args: { + ...args, + legend: { + ...args.legend, + legendSize: undefined, + }, + }, + }); + expect(component.find(Settings).prop('legendSize')).toEqual(130); + }); + it('renders the legend toggle component if uiState is set', async () => { const component = mountWithIntl(); await actWithTimeout(async () => { diff --git a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx index a9b70d1bc2eddc2..36270ef896e46ed 100644 --- a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx +++ b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx @@ -29,6 +29,10 @@ import { getAccessorByDimension, getFormatByAccessor, } from '@kbn/visualizations-plugin/common/utils'; +import { + DEFAULT_LEGEND_SIZE, + LegendSizeToPixels, +} from '@kbn/visualizations-plugin/common/constants'; import type { HeatmapRenderProps, FilterEvent, BrushEvent } from '../../common'; import { applyPaletteParams, findMinMaxByColumnId, getSortPredicate } from './helpers'; import { @@ -485,7 +489,7 @@ export const HeatmapComponent: FC = memo( onElementClick={interactive ? (onElementClick as ElementClickListener) : undefined} showLegend={showLegend ?? args.legend.isVisible} legendPosition={args.legend.position} - legendSize={args.legend.legendSize} + legendSize={LegendSizeToPixels[args.legend.legendSize ?? DEFAULT_LEGEND_SIZE]} legendColorPicker={uiState ? LegendColorPickerWrapper : undefined} debugState={window._echDebugStateFlag ?? false} tooltip={tooltip} diff --git a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.test.ts b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.test.ts index 65f738e8e227d95..6524c15c44af1f0 100644 --- a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.test.ts @@ -68,6 +68,7 @@ describe('interpreter/functions#metric', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts index 2310ffb8c5926a9..add31e7b120148f 100644 --- a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts +++ b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts @@ -146,6 +146,9 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ validateAccessor(args.bucket, input.columns); if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const argsTable: Dimension[] = [ [ args.metric, diff --git a/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts b/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts index 695b7ad4754fab1..8c370480a7be998 100644 --- a/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts +++ b/src/plugins/chart_expressions/expression_metric/common/types/expression_renderers.ts @@ -54,4 +54,6 @@ export interface MetricOptions { color?: string; bgColor?: string; lightText: boolean; + colIndex: number; + rowIndex: number; } diff --git a/src/plugins/chart_expressions/expression_metric/public/components/__snapshots__/metric_component.test.tsx.snap b/src/plugins/chart_expressions/expression_metric/public/components/__snapshots__/metric_component.test.tsx.snap index 684f42d527c19bc..b18c521bea653e1 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/__snapshots__/metric_component.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_metric/public/components/__snapshots__/metric_component.test.tsx.snap @@ -19,6 +19,7 @@ Array [ metric={ Object { "bgColor": undefined, + "colIndex": 0, "color": undefined, "label": "1st percentile of bytes", "lightText": false, @@ -26,6 +27,7 @@ Array [ "value": 182, } } + onFilter={[Function]} style={ Object { "bgColor": false, @@ -53,6 +55,7 @@ Array [ metric={ Object { "bgColor": undefined, + "colIndex": 1, "color": undefined, "label": "99th percentile of bytes", "lightText": false, @@ -60,6 +63,7 @@ Array [ "value": 445842.4634666484, } } + onFilter={[Function]} style={ Object { "bgColor": false, @@ -91,6 +95,7 @@ exports[`MetricVisComponent should render correct structure for single metric 1` metric={ Object { "bgColor": undefined, + "colIndex": 0, "color": undefined, "label": "Count", "lightText": false, @@ -98,6 +103,7 @@ exports[`MetricVisComponent should render correct structure for single metric 1` "value": 4301021, } } + onFilter={[Function]} style={ Object { "bgColor": false, diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_component.tsx b/src/plugins/chart_expressions/expression_metric/public/components/metric_component.tsx index 50853ea6c656912..6fe19c0e725154a 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_component.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/components/metric_component.tsx @@ -63,6 +63,7 @@ class MetricVisComponent extends Component { return dimensions.metrics.reduce( (acc: MetricOptions[], metric: string | ExpressionValueVisDimension) => { const column = getColumnByAccessor(metric, table?.columns); + const colIndex = table?.columns.indexOf(column!); const formatter = getFormatService().deserialize( getFormatByAccessor(metric, table.columns) ); @@ -89,6 +90,7 @@ class MetricVisComponent extends Component { bgColor: shouldBrush && (style.bgColor ?? false) ? color : undefined, lightText: shouldBrush && (style.bgColor ?? false) && needsLightText(color), rowIndex, + colIndex, }; }); @@ -98,20 +100,21 @@ class MetricVisComponent extends Component { ); } - private filterBucket = (row: number) => { + private filterColumn = (row: number, metricColIndex: number) => { const { dimensions } = this.props.visParams; - if (!dimensions.bucket) { - return; - } const table = this.props.visData; + let column = dimensions.bucket ? getAccessor(dimensions.bucket) : metricColIndex; + if (typeof column === 'object' && 'id' in column) { + column = table.columns.indexOf(column); + } this.props.fireEvent({ - name: 'filterBucket', + name: 'filter', data: { data: [ { table, - column: getAccessor(dimensions.bucket), + column, row, }, ], @@ -144,9 +147,7 @@ class MetricVisComponent extends Component { key={index} metric={metric} style={this.props.visParams.metric.style} - onFilter={ - this.props.visParams.dimensions.bucket ? () => this.filterBucket(index) : undefined - } + onFilter={() => this.filterColumn(metric.rowIndex, metric.colIndex)} autoScale={this.props.visParams.metric.autoScale} colorFullBackground={this.props.visParams.metric.colorFullBackground} labelConfig={this.props.visParams.metric.labels} diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_value.test.tsx b/src/plugins/chart_expressions/expression_metric/public/components/metric_value.test.tsx index f86f70341891c0f..fee24d8aa5e7f47 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_value.test.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/components/metric_value.test.tsx @@ -13,7 +13,13 @@ import { MetricVisValue } from './metric_value'; import { MetricOptions, MetricStyle, VisParams } from '../../common/types'; import { LabelPosition } from '../../common/constants'; -const baseMetric: MetricOptions = { label: 'Foo', value: 'foo', lightText: false }; +const baseMetric: MetricOptions = { + label: 'Foo', + value: 'foo', + lightText: false, + rowIndex: 0, + colIndex: 0, +}; const font: MetricStyle = { spec: { fontSize: '12px' }, diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_value.tsx b/src/plugins/chart_expressions/expression_metric/public/components/metric_value.tsx index e948b95af52feca..40de364cfa5dc61 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_value.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/components/metric_value.tsx @@ -8,6 +8,7 @@ import React, { CSSProperties } from 'react'; import classNames from 'classnames'; +import { i18n } from '@kbn/i18n'; import type { MetricOptions, MetricStyle, MetricVisParam } from '../../common/types'; interface MetricVisValueProps { @@ -72,7 +73,13 @@ export const MetricVisValue = ({ if (onFilter) { return ( - ); diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap index 81ada60a772cdad..2a06459822a0eee 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/mosaic_vis_function.test.ts.snap @@ -112,7 +112,7 @@ Object { }, "legendDisplay": "show", "legendPosition": "right", - "legendSize": undefined, + "legendSize": "medium", "maxLegendLines": 2, "metric": Object { "accessor": 0, diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap index 28d5f35c89cbf17..0f64f4c0a477919 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/pie_vis_function.test.ts.snap @@ -112,7 +112,7 @@ Object { }, "legendDisplay": "show", "legendPosition": "right", - "legendSize": undefined, + "legendSize": "small", "maxLegendLines": 2, "metric": Object { "accessor": 0, @@ -246,7 +246,7 @@ Object { }, "legendDisplay": "show", "legendPosition": "right", - "legendSize": undefined, + "legendSize": "small", "maxLegendLines": 2, "metric": Object { "accessor": 0, diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap index e1d9f98f57209e5..9f6210f42b48a7a 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/treemap_vis_function.test.ts.snap @@ -112,7 +112,7 @@ Object { }, "legendDisplay": "show", "legendPosition": "right", - "legendSize": undefined, + "legendSize": "medium", "maxLegendLines": 2, "metric": Object { "accessor": 0, diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap index 33525b33f6f966d..9cdc69904460a78 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/__snapshots__/waffle_vis_function.test.ts.snap @@ -86,7 +86,7 @@ Object { }, "legendDisplay": "show", "legendPosition": "right", - "legendSize": undefined, + "legendSize": "medium", "maxLegendLines": 2, "metric": Object { "accessor": 0, diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts index 250d0f1033ffeef..d7839d1f7d1e98d 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts @@ -47,7 +47,7 @@ export const strings = { }), getLegendSizeArgHelp: () => i18n.translate('expressionPartitionVis.reusable.function.args.legendSizeHelpText', { - defaultMessage: 'Specifies the legend size in pixels', + defaultMessage: 'Specifies the legend size', }), getNestedLegendArgHelp: () => i18n.translate('expressionPartitionVis.reusable.function.args.nestedLegendHelpText', { diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.test.ts index 0ce174b38677faf..54b478e7deed95b 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.test.ts @@ -135,6 +135,7 @@ describe('interpreter/functions#mosaicVis', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts index 74a85dd01e6e420..ae3f17ff8df3ac3 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts @@ -8,6 +8,7 @@ import { Position } from '@elastic/charts'; import { prepareLogTable, validateAccessor } from '@kbn/visualizations-plugin/common/utils'; +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; import { ChartTypes, MosaicVisExpressionFunctionDefinition } from '../types'; import { @@ -64,8 +65,17 @@ export const mosaicVisFunction = (): MosaicVisExpressionFunctionDefinition => ({ strict: true, }, legendSize: { - types: ['number'], + types: ['string'], + default: DEFAULT_LEGEND_SIZE, help: strings.getLegendSizeArgHelp(), + options: [ + LegendSize.AUTO, + LegendSize.SMALL, + LegendSize.MEDIUM, + LegendSize.LARGE, + LegendSize.EXTRA_LARGE, + ], + strict: true, }, nestedLegend: { types: ['boolean'], @@ -134,6 +144,9 @@ export const mosaicVisFunction = (): MosaicVisExpressionFunctionDefinition => ({ }; if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const logTable = prepareLogTable( context, [ diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts index c542a25c308752e..2ac50372e178db3 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.test.ts @@ -14,7 +14,7 @@ import { ValueFormats, LegendDisplay, } from '../types/expression_renderers'; -import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; +import { ExpressionValueVisDimension, LegendSize } from '@kbn/visualizations-plugin/common'; import { Datatable } from '@kbn/expressions-plugin/common/expression_types/specs'; import { pieVisFunction } from './pie_vis_function'; import { PARTITION_LABELS_VALUE } from '../constants'; @@ -31,6 +31,7 @@ describe('interpreter/functions#pieVis', () => { addTooltip: true, legendDisplay: LegendDisplay.SHOW, legendPosition: 'right', + legendSize: LegendSize.SMALL, isDonut: true, emptySizeRatio: EmptySizeRatios.SMALL, nestedLegend: true, @@ -128,6 +129,7 @@ describe('interpreter/functions#pieVis', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts index 9a30008cc6bb317..5b69fbc6194fd89 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts @@ -8,6 +8,7 @@ import { Position } from '@elastic/charts'; import { prepareLogTable, validateAccessor } from '@kbn/visualizations-plugin/common/utils'; +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; import { EmptySizeRatios, LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; import { ChartTypes, PieVisExpressionFunctionDefinition } from '../types'; import { @@ -64,8 +65,17 @@ export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({ strict: true, }, legendSize: { - types: ['number'], + types: ['string'], + default: DEFAULT_LEGEND_SIZE, help: strings.getLegendSizeArgHelp(), + options: [ + LegendSize.AUTO, + LegendSize.SMALL, + LegendSize.MEDIUM, + LegendSize.LARGE, + LegendSize.EXTRA_LARGE, + ], + strict: true, }, nestedLegend: { types: ['boolean'], @@ -154,6 +164,9 @@ export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({ }; if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const logTable = prepareLogTable( context, [ diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.test.ts index 5d2cd5b8a0c38b1..e10dbf09dd179d0 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.test.ts @@ -135,6 +135,7 @@ describe('interpreter/functions#treemapVis', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts index 062cf7e78b4ea8c..427179ca5a25a48 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts @@ -8,6 +8,7 @@ import { Position } from '@elastic/charts'; import { prepareLogTable, validateAccessor } from '@kbn/visualizations-plugin/common/utils'; +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; import { ChartTypes, TreemapVisExpressionFunctionDefinition } from '../types'; import { @@ -64,8 +65,17 @@ export const treemapVisFunction = (): TreemapVisExpressionFunctionDefinition => strict: true, }, legendSize: { - types: ['number'], + types: ['string'], + default: DEFAULT_LEGEND_SIZE, help: strings.getLegendSizeArgHelp(), + options: [ + LegendSize.AUTO, + LegendSize.SMALL, + LegendSize.MEDIUM, + LegendSize.LARGE, + LegendSize.EXTRA_LARGE, + ], + strict: true, }, nestedLegend: { types: ['boolean'], @@ -134,6 +144,9 @@ export const treemapVisFunction = (): TreemapVisExpressionFunctionDefinition => }; if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const logTable = prepareLogTable( context, [ diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.test.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.test.ts index 01cbe844728b326..af36e4ea04a1099 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.test.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.test.ts @@ -106,6 +106,7 @@ describe('interpreter/functions#waffleVis', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts index 2f947a3d5fea69e..0867e6cb9bd7647 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts @@ -8,6 +8,7 @@ import { Position } from '@elastic/charts'; import { prepareLogTable, validateAccessor } from '@kbn/visualizations-plugin/common/utils'; +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; import { ChartTypes, WaffleVisExpressionFunctionDefinition } from '../types'; import { @@ -63,8 +64,17 @@ export const waffleVisFunction = (): WaffleVisExpressionFunctionDefinition => ({ strict: true, }, legendSize: { - types: ['number'], + types: ['string'], + default: DEFAULT_LEGEND_SIZE, help: strings.getLegendSizeArgHelp(), + options: [ + LegendSize.AUTO, + LegendSize.SMALL, + LegendSize.MEDIUM, + LegendSize.LARGE, + LegendSize.EXTRA_LARGE, + ], + strict: true, }, truncateLegend: { types: ['boolean'], @@ -129,6 +139,9 @@ export const waffleVisFunction = (): WaffleVisExpressionFunctionDefinition => ({ }; if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const logTable = prepareLogTable( context, [ diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts b/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts index 05613af4f2f33ee..89a242fe26de175 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/types/expression_renderers.ts @@ -11,6 +11,7 @@ import type { PaletteOutput } from '@kbn/coloring'; import { Datatable, DatatableColumn } from '@kbn/expressions-plugin/common'; import { SerializedFieldFormat } from '@kbn/field-formats-plugin/common'; import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; import { ChartTypes, ExpressionValuePartitionLabels } from './expression_functions'; export enum EmptySizeRatios { @@ -52,7 +53,7 @@ interface VisCommonParams { legendPosition: Position; truncateLegend: boolean; maxLegendLines: number; - legendSize?: number; + legendSize?: LegendSize; ariaLabel?: string; } diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap b/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap index 3c48d3cb36771c7..0fcee477c99de0d 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/__snapshots__/partition_vis_component.test.tsx.snap @@ -247,6 +247,7 @@ exports[`PartitionVisComponent should render correct structure for donut 1`] = ` legendColorPicker={[Function]} legendMaxDepth={1} legendPosition="right" + legendSize={130} onElementClick={[Function]} onRenderChange={[Function]} showLegend={true} @@ -674,6 +675,7 @@ exports[`PartitionVisComponent should render correct structure for mosaic 1`] = legendAction={[Function]} legendColorPicker={[Function]} legendPosition="right" + legendSize={130} onElementClick={[Function]} onRenderChange={[Function]} showLegend={true} @@ -1054,6 +1056,7 @@ exports[`PartitionVisComponent should render correct structure for pie 1`] = ` legendColorPicker={[Function]} legendMaxDepth={1} legendPosition="right" + legendSize={130} onElementClick={[Function]} onRenderChange={[Function]} showLegend={true} @@ -1465,6 +1468,7 @@ exports[`PartitionVisComponent should render correct structure for treemap 1`] = legendAction={[Function]} legendColorPicker={[Function]} legendPosition="right" + legendSize={130} onElementClick={[Function]} onRenderChange={[Function]} showLegend={true} @@ -1866,6 +1870,7 @@ exports[`PartitionVisComponent should render correct structure for waffle 1`] = legendColorPicker={[Function]} legendMaxDepth={1} legendPosition="right" + legendSize={130} onElementClick={[Function]} onRenderChange={[Function]} showLegend={true} diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx index 648df546b299275..70c120e4fd7599e 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx @@ -25,6 +25,7 @@ import { createMockWaffleParams, } from '../mocks'; import { ChartTypes } from '../../common/types'; +import { LegendSize } from '@kbn/visualizations-plugin/common'; jest.mock('@elastic/charts', () => { const original = jest.requireActual('@elastic/charts'); @@ -177,6 +178,35 @@ describe('PartitionVisComponent', function () { expect(component.find(Settings).prop('legendMaxDepth')).toBeUndefined(); }); + it('sets correct legend sizes', () => { + const component = shallow( + + ); + expect(component.find(Settings).prop('legendSize')).toEqual(80); + + component.setProps({ + visParams: { + ...visParams, + legendSize: LegendSize.AUTO, + }, + }); + expect(component.find(Settings).prop('legendSize')).toBeUndefined(); + + component.setProps({ + visParams: { + ...visParams, + legendSize: undefined, + }, + }); + expect(component.find(Settings).prop('legendSize')).toEqual(130); + }); + it('defaults on displaying the tooltip', () => { const component = shallow(); expect(component.find(Settings).prop('tooltip')).toStrictEqual({ type: TooltipType.Follow }); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx index ef6d0d1c4525cc7..d25126869e0872d 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx @@ -22,7 +22,11 @@ import { import { useEuiTheme } from '@elastic/eui'; import type { PaletteRegistry } from '@kbn/coloring'; import { LegendToggle, ChartsPluginSetup } from '@kbn/charts-plugin/public'; -import type { PersistedState } from '@kbn/visualizations-plugin/public'; +import { + DEFAULT_LEGEND_SIZE, + LegendSizeToPixels, +} from '@kbn/visualizations-plugin/common/constants'; +import { PersistedState } from '@kbn/visualizations-plugin/public'; import { getColumnByAccessor } from '@kbn/visualizations-plugin/common/utils'; import { Datatable, @@ -387,7 +391,7 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { showLegend ?? shouldShowLegend(visType, visParams.legendDisplay, bucketColumns) } legendPosition={legendPosition} - legendSize={visParams.legendSize} + legendSize={LegendSizeToPixels[visParams.legendSize ?? DEFAULT_LEGEND_SIZE]} legendMaxDepth={visParams.nestedLegend ? undefined : 1} legendColorPicker={props.uiState ? LegendColorPickerWrapper : undefined} flatLegend={flatLegend} diff --git a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts index c9ddd7c30557b9b..ccc365096495be2 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts +++ b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.test.ts @@ -93,6 +93,7 @@ describe('interpreter/functions#tagcloud', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx index 49f376a8a4aa363..96857c2ec7426b9 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx +++ b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx @@ -165,7 +165,7 @@ export const TagCloudChart = ({ } fireEvent({ - name: 'filterBucket', + name: 'filter', data: { data: [ { 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 3a4a1fdb813fc64..b8969fd59976590 100644 --- a/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/__mocks__/index.ts @@ -56,7 +56,6 @@ export const sampleLayer: DataLayerConfig = { splitAccessor: 'd', columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, table: createSampleDatatableWithRows([]), @@ -108,6 +107,8 @@ export const createArgsWithLayers = ( type: 'axisExtentConfig', }, layers: Array.isArray(layers) ? layers : [layers], + yLeftScale: 'linear', + yRightScale: 'linear', }); export function sampleArgs() { diff --git a/src/plugins/chart_expressions/expression_xy/common/constants.ts b/src/plugins/chart_expressions/expression_xy/common/constants.ts index 931ece6ef8a7861..68ac2963c964693 100644 --- a/src/plugins/chart_expressions/expression_xy/common/constants.ts +++ b/src/plugins/chart_expressions/expression_xy/common/constants.ts @@ -10,7 +10,6 @@ export const XY_VIS = 'xyVis'; export const LAYERED_XY_VIS = 'layeredXyVis'; export const Y_CONFIG = 'yConfig'; export const EXTENDED_Y_CONFIG = 'extendedYConfig'; -export const MULTITABLE = 'lens_multitable'; export const DATA_LAYER = 'dataLayer'; export const EXTENDED_DATA_LAYER = 'extendedDataLayer'; export const LEGEND_CONFIG = 'legendConfig'; 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 new file mode 100644 index 000000000000000..4f3ad589f1eea55 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/__snapshots__/xy_vis.test.ts.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`xyVis it should throw error if splitColumnAccessor is pointing to the absent column 1`] = `"Provided column name or index is invalid: absent-accessor"`; + +exports[`xyVis it should throw error if splitRowAccessor is pointing to the absent column 1`] = `"Provided column name or index is invalid: absent-accessor"`; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts index 49446310a894b5d..0c9085cce7664db 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_data_layer_args.ts @@ -6,23 +6,27 @@ * Side Public License, v 1. */ -import { SeriesTypes, XScaleTypes, YScaleTypes, Y_CONFIG } from '../constants'; +import { ArgumentType } from '@kbn/expressions-plugin/common'; +import { SeriesTypes, XScaleTypes, Y_CONFIG } from '../constants'; import { strings } from '../i18n'; -import { DataLayerFn, ExtendedDataLayerFn } from '../types'; +import { DataLayerArgs, ExtendedDataLayerArgs } from '../types'; -type CommonDataLayerFn = DataLayerFn | ExtendedDataLayerFn; +type CommonDataLayerArgs = ExtendedDataLayerArgs | DataLayerArgs; +type CommonDataLayerFnArgs = { + [key in keyof CommonDataLayerArgs]: ArgumentType; +}; -export const commonDataLayerArgs: CommonDataLayerFn['args'] = { +export const commonDataLayerArgs: Omit< + CommonDataLayerFnArgs, + 'accessors' | 'xAccessor' | 'splitAccessor' +> = { hide: { types: ['boolean'], default: false, help: strings.getHideHelp(), }, - xAccessor: { - types: ['string'], - help: strings.getXAccessorHelp(), - }, seriesType: { + aliases: ['_'], types: ['string'], options: [...Object.values(SeriesTypes)], help: strings.getSeriesTypeHelp(), @@ -40,21 +44,6 @@ export const commonDataLayerArgs: CommonDataLayerFn['args'] = { default: false, help: strings.getIsHistogramHelp(), }, - yScaleType: { - options: [...Object.values(YScaleTypes)], - help: strings.getYScaleTypeHelp(), - default: YScaleTypes.LINEAR, - strict: true, - }, - splitAccessor: { - types: ['string'], - help: strings.getSplitAccessorHelp(), - }, - accessors: { - types: ['string'], - help: strings.getAccessorsHelp(), - multi: true, - }, yConfig: { types: [Y_CONFIG], help: strings.getYConfigHelp(), diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_reference_line_layer_args.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_reference_line_layer_args.ts index f338e08a8894053..d85f5ae2b2f7700 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_reference_line_layer_args.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/common_reference_line_layer_args.ts @@ -12,12 +12,7 @@ import { ReferenceLineLayerFn, ExtendedReferenceLineLayerFn } from '../types'; type CommonReferenceLineLayerFn = ReferenceLineLayerFn | ExtendedReferenceLineLayerFn; -export const commonReferenceLineLayerArgs: CommonReferenceLineLayerFn['args'] = { - accessors: { - types: ['string'], - help: strings.getRLAccessorsHelp(), - multi: true, - }, +export const commonReferenceLineLayerArgs: Omit = { yConfig: { types: [EXTENDED_Y_CONFIG], help: strings.getRLYConfigHelp(), 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 f80d8145710762a..0dbe71ef554cc25 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 @@ -17,6 +17,7 @@ import { TICK_LABELS_CONFIG, ValueLabelModes, XYCurveTypes, + YScaleTypes, } from '../constants'; import { strings } from '../i18n'; import { LayeredXyVisFn, XyVisFn } from '../types'; @@ -46,6 +47,18 @@ export const commonXYArgs: CommonXYFn['args'] = { help: strings.getYRightExtentHelp(), default: `{${AXIS_EXTENT_CONFIG}}`, }, + yLeftScale: { + options: [...Object.values(YScaleTypes)], + help: strings.getYLeftScaleTypeHelp(), + default: YScaleTypes.LINEAR, + strict: true, + }, + yRightScale: { + options: [...Object.values(YScaleTypes)], + help: strings.getYRightScaleTypeHelp(), + default: YScaleTypes.LINEAR, + strict: true, + }, legend: { types: [LEGEND_CONFIG], help: strings.getLegendHelp(), diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/data_layer.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/data_layer.test.ts deleted file mode 100644 index 518690d47bfcb94..000000000000000 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/data_layer.test.ts +++ /dev/null @@ -1,38 +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 { DataLayerArgs } from '../types'; -import { createMockExecutionContext } from '@kbn/expressions-plugin/common/mocks'; -import { mockPaletteOutput, sampleArgs } from '../__mocks__'; -import { LayerTypes } from '../constants'; -import { dataLayerFunction } from './data_layer'; - -describe('dataLayerConfig', () => { - test('produces the correct arguments', () => { - const { data } = sampleArgs(); - const args: DataLayerArgs = { - seriesType: 'line', - xAccessor: 'c', - accessors: ['a', 'b'], - splitAccessor: 'd', - xScaleType: 'linear', - yScaleType: 'linear', - isHistogram: false, - palette: mockPaletteOutput, - }; - - const result = dataLayerFunction.fn(data, args, createMockExecutionContext()); - - expect(result).toEqual({ - type: 'dataLayer', - layerType: LayerTypes.DATA, - ...args, - table: data, - }); - }); -}); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/data_layer.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/data_layer.ts deleted file mode 100644 index f36a0ea4c101f30..000000000000000 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/data_layer.ts +++ /dev/null @@ -1,30 +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 { DataLayerFn } from '../types'; -import { DATA_LAYER, LayerTypes } from '../constants'; -import { strings } from '../i18n'; -import { commonDataLayerArgs } from './common_data_layer_args'; - -export const dataLayerFunction: DataLayerFn = { - name: DATA_LAYER, - aliases: [], - type: DATA_LAYER, - help: strings.getDataLayerFnHelp(), - inputTypes: ['datatable'], - args: { ...commonDataLayerArgs }, - fn(table, args) { - return { - type: DATA_LAYER, - ...args, - accessors: args.accessors ?? [], - layerType: LayerTypes.DATA, - table, - }; - }, -}; 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 84c1213fc069d48..a7aa63645d1192c 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 @@ -7,7 +7,7 @@ */ import { ExtendedDataLayerFn } from '../types'; -import { EXTENDED_DATA_LAYER, LayerTypes } from '../constants'; +import { EXTENDED_DATA_LAYER } from '../constants'; import { strings } from '../i18n'; import { commonDataLayerArgs } from './common_data_layer_args'; @@ -19,6 +19,19 @@ export const extendedDataLayerFunction: ExtendedDataLayerFn = { inputTypes: ['datatable'], args: { ...commonDataLayerArgs, + xAccessor: { + types: ['string'], + help: strings.getXAccessorHelp(), + }, + splitAccessor: { + types: ['string'], + help: strings.getSplitAccessorHelp(), + }, + accessors: { + types: ['string'], + help: strings.getAccessorsHelp(), + multi: true, + }, table: { types: ['datatable'], help: strings.getTableHelp(), @@ -28,13 +41,8 @@ export const extendedDataLayerFunction: ExtendedDataLayerFn = { help: strings.getLayerIdHelp(), }, }, - fn(input, args) { - return { - type: EXTENDED_DATA_LAYER, - ...args, - accessors: args.accessors ?? [], - layerType: LayerTypes.DATA, - table: args.table ?? input, - }; + async fn(input, args, context) { + const { extendedDataLayerFn } = await import('./extended_data_layer_fn'); + return await extendedDataLayerFn(input, args, context); }, }; 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 new file mode 100644 index 000000000000000..c4d714f11ddd9a8 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_data_layer_fn.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { validateAccessor } from '@kbn/visualizations-plugin/common/utils'; +import { ExtendedDataLayerArgs, ExtendedDataLayerFn } from '../types'; +import { EXTENDED_DATA_LAYER, LayerTypes } from '../constants'; +import { getAccessors } from '../helpers'; + +export const extendedDataLayerFn: ExtendedDataLayerFn['fn'] = async (data, args, context) => { + const table = args.table ?? data; + const accessors = getAccessors(args, table); + + validateAccessor(accessors.xAccessor, table.columns); + validateAccessor(accessors.splitAccessor, table.columns); + accessors.accessors.forEach((accessor) => validateAccessor(accessor, table.columns)); + + return { + type: EXTENDED_DATA_LAYER, + ...args, + layerType: LayerTypes.DATA, + ...accessors, + table, + }; +}; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_reference_line_layer.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_reference_line_layer.ts index 4f75838bea114f5..41b264cf53a4dbf 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_reference_line_layer.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/extended_reference_line_layer.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { validateAccessor } from '@kbn/visualizations-plugin/common/utils'; import { LayerTypes, EXTENDED_REFERENCE_LINE_LAYER } from '../constants'; import { ExtendedReferenceLineLayerFn } from '../types'; import { strings } from '../i18n'; @@ -19,6 +20,11 @@ export const extendedReferenceLineLayerFunction: ExtendedReferenceLineLayerFn = inputTypes: ['datatable'], args: { ...commonReferenceLineLayerArgs, + accessors: { + types: ['string'], + help: strings.getRLAccessorsHelp(), + multi: true, + }, table: { types: ['datatable'], help: strings.getTableHelp(), @@ -29,12 +35,16 @@ export const extendedReferenceLineLayerFunction: ExtendedReferenceLineLayerFn = }, }, fn(input, args) { + const table = args.table ?? input; + const accessors = args.accessors ?? []; + accessors.forEach((accessor) => validateAccessor(accessor, table.columns)); + return { type: EXTENDED_REFERENCE_LINE_LAYER, ...args, accessors: args.accessors ?? [], layerType: LayerTypes.REFERENCELINE, - table: args.table ?? input, + table, }; }, }; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/index.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/index.ts index ab1d570a07351e7..30a76217b5c0ea8 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/index.ts @@ -13,7 +13,6 @@ export * from './annotation_layer'; export * from './extended_annotation_layer'; export * from './y_axis_config'; export * from './extended_y_axis_config'; -export * from './data_layer'; export * from './extended_data_layer'; export * from './grid_lines_config'; export * from './axis_extent_config'; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.ts index 6b926e1ceff05df..695bd16613715aa 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import { i18n } from '@kbn/i18n'; import { LayeredXyVisFn } from '../types'; import { EXTENDED_DATA_LAYER, @@ -26,9 +25,7 @@ export const layeredXyVisFunction: LayeredXyVisFn = { ...commonXYArgs, layers: { types: [EXTENDED_DATA_LAYER, EXTENDED_REFERENCE_LINE_LAYER, EXTENDED_ANNOTATION_LAYER], - help: i18n.translate('expressionXY.layeredXyVis.layers.help', { - defaultMessage: 'Layers of visual series', - }), + help: strings.getLayersHelp(), multi: true, }, }, diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/legend_config.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/legend_config.ts index 2b383f1899d44bc..ddb46d5e55f131e 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/legend_config.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/legend_config.ts @@ -8,6 +8,7 @@ import { HorizontalAlignment, Position, VerticalAlignment } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; +import { DEFAULT_LEGEND_SIZE, LegendSize } from '@kbn/visualizations-plugin/common/constants'; import { LEGEND_CONFIG } from '../constants'; import { LegendConfigFn } from '../types'; @@ -85,10 +86,19 @@ export const legendConfigFunction: LegendConfigFn = { }), }, legendSize: { - types: ['number'], + types: ['string'], + default: DEFAULT_LEGEND_SIZE, help: i18n.translate('expressionXY.legendConfig.legendSize.help', { - defaultMessage: 'Specifies the legend size in pixels.', + defaultMessage: 'Specifies the legend size.', }), + options: [ + LegendSize.AUTO, + LegendSize.SMALL, + LegendSize.MEDIUM, + LegendSize.LARGE, + LegendSize.EXTRA_LARGE, + ], + strict: true, }, }, async fn(input, args, handlers) { diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts index 9c6e27c9585308d..04c06f92d616f33 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/reference_line_layer.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { validateAccessor } from '@kbn/visualizations-plugin/common/utils'; import { LayerTypes, REFERENCE_LINE_LAYER } from '../constants'; import { ReferenceLineLayerFn } from '../types'; import { strings } from '../i18n'; @@ -17,13 +18,23 @@ export const referenceLineLayerFunction: ReferenceLineLayerFn = { type: REFERENCE_LINE_LAYER, help: strings.getRLHelp(), inputTypes: ['datatable'], - args: { ...commonReferenceLineLayerArgs }, + args: { + ...commonReferenceLineLayerArgs, + accessors: { + types: ['string', 'vis_dimension'], + help: strings.getRLAccessorsHelp(), + multi: true, + }, + }, fn(table, args) { + const accessors = args.accessors ?? []; + accessors.forEach((accessor) => validateAccessor(accessor, table.columns)); + return { type: REFERENCE_LINE_LAYER, ...args, - accessors: args.accessors ?? [], layerType: LayerTypes.REFERENCELINE, + accessors, table, }; }, 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 688efbe122f3e5a..cb6527eb1c393a7 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 @@ -15,16 +15,64 @@ describe('xyVis', () => { test('it renders with the specified data and args', async () => { const { data, args } = sampleArgs(); const { layers, ...rest } = args; + const { layerId, layerType, table, type, ...restLayerArgs } = sampleLayer; const result = await xyVisFunction.fn( data, - { ...rest, dataLayers: [sampleLayer], referenceLineLayers: [], annotationLayers: [] }, + { ...rest, ...restLayerArgs, referenceLineLayers: [], annotationLayers: [] }, createMockExecutionContext() ); expect(result).toEqual({ type: 'render', as: XY_VIS, - value: { args: { ...rest, layers: [sampleLayer] } }, + value: { + args: { + ...rest, + layers: [{ layerType, table: data, layerId: 'dataLayers-0', type, ...restLayerArgs }], + }, + }, }); }); + + test('it should throw error if splitRowAccessor is pointing to the absent column', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + const { layerId, layerType, table, type, ...restLayerArgs } = sampleLayer; + const splitRowAccessor = 'absent-accessor'; + + expect( + xyVisFunction.fn( + data, + { + ...rest, + ...restLayerArgs, + referenceLineLayers: [], + annotationLayers: [], + splitRowAccessor, + }, + createMockExecutionContext() + ) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test('it should throw error if splitColumnAccessor is pointing to the absent column', async () => { + const { data, args } = sampleArgs(); + const { layers, ...rest } = args; + const { layerId, layerType, table, type, ...restLayerArgs } = sampleLayer; + const splitColumnAccessor = 'absent-accessor'; + + expect( + xyVisFunction.fn( + data, + { + ...rest, + ...restLayerArgs, + referenceLineLayers: [], + annotationLayers: [], + splitColumnAccessor, + }, + 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 2e97cb00b3e5586..e4e519b0a74339c 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 @@ -7,9 +7,10 @@ */ import { XyVisFn } from '../types'; -import { XY_VIS, DATA_LAYER, REFERENCE_LINE_LAYER, ANNOTATION_LAYER } from '../constants'; +import { XY_VIS, REFERENCE_LINE_LAYER, ANNOTATION_LAYER } from '../constants'; import { strings } from '../i18n'; import { commonXYArgs } from './common_xy_args'; +import { commonDataLayerArgs } from './common_data_layer_args'; export const xyVisFunction: XyVisFn = { name: XY_VIS, @@ -18,9 +19,18 @@ export const xyVisFunction: XyVisFn = { help: strings.getXYHelp(), args: { ...commonXYArgs, - dataLayers: { - types: [DATA_LAYER], - help: strings.getDataLayerHelp(), + ...commonDataLayerArgs, + xAccessor: { + types: ['string', 'vis_dimension'], + help: strings.getXAccessorHelp(), + }, + splitAccessor: { + types: ['string', 'vis_dimension'], + help: strings.getSplitAccessorHelp(), + }, + accessors: { + types: ['string', 'vis_dimension'], + help: strings.getAccessorsHelp(), multi: true, }, referenceLineLayers: { @@ -33,6 +43,14 @@ export const xyVisFunction: XyVisFn = { help: strings.getAnnotationLayerHelp(), multi: true, }, + splitColumnAccessor: { + types: ['vis_dimension', 'string'], + help: strings.getSplitColumnAccessorHelp(), + }, + splitRowAccessor: { + types: ['vis_dimension', 'string'], + help: strings.getSplitRowAccessorHelp(), + }, }, 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 1bd75e1296c6c78..04d0b954c5d5cf2 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 @@ -6,10 +6,16 @@ * Side Public License, v 1. */ -import { Dimension, prepareLogTable } from '@kbn/visualizations-plugin/common/utils'; -import { LayerTypes, XY_VIS_RENDERER } from '../constants'; -import { appendLayerIds } from '../helpers'; -import { XYLayerConfig, XyVisFn } from '../types'; +import { + Dimension, + prepareLogTable, + validateAccessor, +} from '@kbn/visualizations-plugin/common/utils'; +import type { Datatable } from '@kbn/expressions-plugin/common'; +import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common/expression_functions'; +import { LayerTypes, XY_VIS_RENDERER, DATA_LAYER } from '../constants'; +import { appendLayerIds, getAccessors } from '../helpers'; +import { DataLayerConfigResult, XYLayerConfig, XyVisFn, XYArgs } from '../types'; import { getLayerDimensions } from '../utils'; import { hasAreaLayer, @@ -20,8 +26,47 @@ import { validateValueLabels, } from './validate'; +const createDataLayer = (args: XYArgs, table: Datatable): DataLayerConfigResult => ({ + type: DATA_LAYER, + seriesType: args.seriesType, + hide: args.hide, + columnToLabel: args.columnToLabel, + xScaleType: args.xScaleType, + isHistogram: args.isHistogram, + palette: args.palette, + yConfig: args.yConfig, + layerType: LayerTypes.DATA, + table, + ...getAccessors(args, table), +}); + export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { - const { dataLayers = [], referenceLineLayers = [], annotationLayers = [], ...restArgs } = args; + validateAccessor(args.splitRowAccessor, data.columns); + validateAccessor(args.splitColumnAccessor, data.columns); + + const { + referenceLineLayers = [], + annotationLayers = [], + // data_layer args + seriesType, + accessors, + xAccessor, + hide, + splitAccessor, + columnToLabel, + xScaleType, + isHistogram, + yConfig, + palette, + ...restArgs + } = args; + + const dataLayers: DataLayerConfigResult[] = [createDataLayer(args, data)]; + + validateAccessor(dataLayers[0].xAccessor, data.columns); + validateAccessor(dataLayers[0].splitAccessor, data.columns); + dataLayers[0].accessors.forEach((accessor) => validateAccessor(accessor, data.columns)); + const layers: XYLayerConfig[] = [ ...appendLayerIds(dataLayers, 'dataLayers'), ...appendLayerIds(referenceLineLayers, 'referenceLineLayers'), @@ -29,6 +74,9 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { ]; if (handlers.inspectorAdapters.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const layerDimensions = layers.reduce((dimensions, layer) => { if (layer.layerType === LayerTypes.ANNOTATIONS) { return dimensions; diff --git a/src/plugins/chart_expressions/expression_xy/common/helpers/index.ts b/src/plugins/chart_expressions/expression_xy/common/helpers/index.ts index 55c4136e0c00d63..56416261316aa0f 100644 --- a/src/plugins/chart_expressions/expression_xy/common/helpers/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/helpers/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { appendLayerIds } from './layers'; +export { appendLayerIds, getAccessors } from './layers'; 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 d62ea264acb1a2d..667b2697e480f13 100644 --- a/src/plugins/chart_expressions/expression_xy/common/helpers/layers.ts +++ b/src/plugins/chart_expressions/expression_xy/common/helpers/layers.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { Datatable, PointSeriesColumnNames } from '@kbn/expressions-plugin/common'; import { WithLayerId } from '../types'; function isWithLayerId(layer: T): layer is T & WithLayerId { @@ -25,3 +26,20 @@ export function appendLayerIds( layerId: isWithLayerId(l) ? l.layerId : generateLayerId(keyword, index), })); } + +export function getAccessors( + 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)) { + 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] : []; + } + + return { splitAccessor, xAccessor, accessors }; +} 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 225f9de0d6a7ce8..a69f0034b9e2324 100644 --- a/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx +++ b/src/plugins/chart_expressions/expression_xy/common/i18n/index.tsx @@ -49,6 +49,14 @@ export const strings = { i18n.translate('expressionXY.xyVis.yRightExtent.help', { defaultMessage: 'Y right axis extents', }), + getYLeftScaleTypeHelp: () => + i18n.translate('expressionXY.xyVis.yLeftScaleType.help', { + defaultMessage: 'The scale type of the left y axis', + }), + getYRightScaleTypeHelp: () => + i18n.translate('expressionXY.xyVis.yRightScaleType.help', { + defaultMessage: 'The scale type of the right y axis', + }), getLegendHelp: () => i18n.translate('expressionXY.xyVis.legend.help', { defaultMessage: 'Configure the chart legend.', @@ -113,6 +121,18 @@ export const strings = { i18n.translate('expressionXY.xyVis.ariaLabel.help', { defaultMessage: 'Specifies the aria label of the xy chart', }), + getSplitColumnAccessorHelp: () => + i18n.translate('expressionXY.xyVis.splitColumnAccessor.help', { + defaultMessage: 'Specifies split column of the xy chart', + }), + getSplitRowAccessorHelp: () => + i18n.translate('expressionXY.xyVis.splitRowAccessor.help', { + defaultMessage: 'Specifies split row of the xy chart', + }), + getLayersHelp: () => + i18n.translate('expressionXY.layeredXyVis.layers.help', { + defaultMessage: 'Layers of visual series', + }), getDataLayerFnHelp: () => i18n.translate('expressionXY.dataLayer.help', { defaultMessage: `Configure a layer in the xy chart`, @@ -137,10 +157,6 @@ export const strings = { i18n.translate('expressionXY.dataLayer.isHistogram.help', { defaultMessage: 'Whether to layout the chart as a histogram', }), - getYScaleTypeHelp: () => - i18n.translate('expressionXY.dataLayer.yScaleType.help', { - defaultMessage: 'The scale type of the y axes', - }), getSplitAccessorHelp: () => i18n.translate('expressionXY.dataLayer.splitAccessor.help', { defaultMessage: 'The column to split by', diff --git a/src/plugins/chart_expressions/expression_xy/common/index.ts b/src/plugins/chart_expressions/expression_xy/common/index.ts index 4bee4a3e7f2b605..7211a7a7db1b762 100755 --- a/src/plugins/chart_expressions/expression_xy/common/index.ts +++ b/src/plugins/chart_expressions/expression_xy/common/index.ts @@ -29,7 +29,6 @@ export type { LegendConfig, IconPosition, DataLayerArgs, - LensMultiTable, ValueLabelMode, AxisExtentMode, DataLayerConfig, 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 b3c7bca93ca29fc..375ee584f380c2b 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 @@ -10,14 +10,15 @@ import { HorizontalAlignment, Position, VerticalAlignment } from '@elastic/chart import { $Values } from '@kbn/utility-types'; import type { PaletteOutput } from '@kbn/coloring'; import { Datatable, ExpressionFunctionDefinition } from '@kbn/expressions-plugin'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; import { EventAnnotationOutput } from '@kbn/event-annotation-plugin/common'; +import type { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common/expression_functions'; import { AxisExtentModes, FillStyles, FittingFunctions, IconPositions, LayerTypes, - MULTITABLE, LineStyles, SeriesTypes, ValueLabelModes, @@ -94,13 +95,12 @@ export interface YConfig { } export interface DataLayerArgs { - accessors: string[]; + accessors: Array; seriesType: SeriesType; - xAccessor?: string; + xAccessor?: string | ExpressionValueVisDimension; hide?: boolean; - splitAccessor?: string; + splitAccessor?: string | ExpressionValueVisDimension; columnToLabel?: string; // Actually a JSON key-value pair - yScaleType: YScaleType; xScaleType: XScaleType; isHistogram: boolean; palette: PaletteOutput; @@ -119,7 +119,6 @@ export interface ExtendedDataLayerArgs { hide?: boolean; splitAccessor?: string; columnToLabel?: string; // Actually a JSON key-value pair - yScaleType: YScaleType; xScaleType: XScaleType; isHistogram: boolean; palette: PaletteOutput; @@ -170,7 +169,7 @@ export interface LegendConfig { * Exact legend width (vertical) or height (horizontal) * Limited to max of 70% of the chart container dimension Vertical legends limited to min of 30% of computed width */ - legendSize?: number; + legendSize?: LegendSize; } export interface LabelsOrientationConfig { @@ -180,17 +179,18 @@ export interface LabelsOrientationConfig { } // Arguments to XY chart expression, with computed properties -export interface XYArgs { +export interface XYArgs extends DataLayerArgs { xTitle: string; yTitle: string; yRightTitle: string; yLeftExtent: AxisExtentConfigResult; yRightExtent: AxisExtentConfigResult; + yLeftScale: YScaleType; + yRightScale: YScaleType; legend: LegendConfigResult; endValue?: EndValue; emphasizeFitting?: boolean; valueLabels: ValueLabelMode; - dataLayers: DataLayerConfigResult[]; referenceLineLayers: ReferenceLineLayerConfigResult[]; annotationLayers: AnnotationLayerConfigResult[]; fittingFunction?: FittingFunction; @@ -203,6 +203,8 @@ export interface XYArgs { hideEndzones?: boolean; valuesInLegend?: boolean; ariaLabel?: string; + splitRowAccessor?: ExpressionValueVisDimension | string; + splitColumnAccessor?: ExpressionValueVisDimension | string; } export interface LayeredXYArgs { @@ -211,6 +213,8 @@ export interface LayeredXYArgs { yRightTitle: string; yLeftExtent: AxisExtentConfigResult; yRightExtent: AxisExtentConfigResult; + yLeftScale: YScaleType; + yRightScale: YScaleType; legend: LegendConfigResult; endValue?: EndValue; emphasizeFitting?: boolean; @@ -234,11 +238,13 @@ export interface XYProps { yRightTitle: string; yLeftExtent: AxisExtentConfigResult; yRightExtent: AxisExtentConfigResult; + yLeftScale: YScaleType; + yRightScale: YScaleType; legend: LegendConfigResult; - valueLabels: ValueLabelMode; - layers: CommonXYLayerConfig[]; endValue?: EndValue; emphasizeFitting?: boolean; + valueLabels: ValueLabelMode; + layers: CommonXYLayerConfig[]; fittingFunction?: FittingFunction; axisTitlesVisibilitySettings?: AxisTitlesVisibilityConfigResult; tickLabelsVisibilitySettings?: TickLabelsConfigResult; @@ -249,6 +255,8 @@ export interface XYProps { hideEndzones?: boolean; valuesInLegend?: boolean; ariaLabel?: string; + splitRowAccessor?: ExpressionValueVisDimension | string; + splitColumnAccessor?: ExpressionValueVisDimension | string; } export interface AnnotationLayerArgs { @@ -271,7 +279,7 @@ export type ExtendedAnnotationLayerConfigResult = ExtendedAnnotationLayerArgs & }; export interface ReferenceLineLayerArgs { - accessors: string[]; + accessors: Array; columnToLabel?: string; yConfig?: ExtendedYConfigResult[]; } @@ -296,15 +304,6 @@ export type XYExtendedLayerConfigResult = | ExtendedReferenceLineLayerConfigResult | ExtendedAnnotationLayerConfigResult; -export interface LensMultiTable { - type: typeof MULTITABLE; - tables: Record; - dateRange?: { - fromDate: Date; - toDate: Date; - }; -} - export type ReferenceLineLayerConfigResult = ReferenceLineLayerArgs & { type: typeof REFERENCE_LINE_LAYER; layerType: typeof LayerTypes.REFERENCELINE; @@ -385,17 +384,11 @@ export type LayeredXyVisFn = ExpressionFunctionDefinition< Promise >; -export type DataLayerFn = ExpressionFunctionDefinition< - typeof DATA_LAYER, - Datatable, - DataLayerArgs, - DataLayerConfigResult ->; export type ExtendedDataLayerFn = ExpressionFunctionDefinition< typeof EXTENDED_DATA_LAYER, Datatable, ExtendedDataLayerArgs, - ExtendedDataLayerConfigResult + Promise >; export type ReferenceLineLayerFn = ExpressionFunctionDefinition< diff --git a/src/plugins/chart_expressions/expression_xy/public/__mocks__/index.tsx b/src/plugins/chart_expressions/expression_xy/public/__mocks__/index.tsx index 194bfc2bf5c9d1a..77ce5ee76ebbfc0 100644 --- a/src/plugins/chart_expressions/expression_xy/public/__mocks__/index.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/__mocks__/index.tsx @@ -8,7 +8,6 @@ import { Datatable } from '@kbn/expressions-plugin/common'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; -import { LensMultiTable } from '../../common'; import { LayerTypes } from '../../common/constants'; import { DataLayerConfig, XYProps } from '../../common/types'; import { mockPaletteOutput, sampleArgs } from '../../common/__mocks__'; @@ -21,151 +20,142 @@ export const chartsActiveCursorService = chartStartContract.activeCursor; export const paletteService = chartPluginMock.createPaletteRegistry(); -export const dateHistogramData: LensMultiTable = { - type: 'lens_multitable', - tables: { - timeLayer: { - type: 'datatable', - rows: [ - { - xAccessorId: 1585758120000, - splitAccessorId: "Men's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585758360000, - splitAccessorId: "Women's Accessories", - yAccessorId: 1, - }, - { - xAccessorId: 1585758360000, - splitAccessorId: "Women's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585759380000, - splitAccessorId: "Men's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585759380000, - splitAccessorId: "Men's Shoes", - yAccessorId: 1, - }, - { - xAccessorId: 1585759380000, - splitAccessorId: "Women's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585760700000, - splitAccessorId: "Men's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585760760000, - splitAccessorId: "Men's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585760760000, - splitAccessorId: "Men's Shoes", - yAccessorId: 1, - }, - { - xAccessorId: 1585761120000, - splitAccessorId: "Men's Shoes", - yAccessorId: 1, - }, - ], - columns: [ - { - id: 'xAccessorId', - name: 'order_date per minute', - meta: { - type: 'date', +export const dateHistogramData: Datatable = { + type: 'datatable', + rows: [ + { + xAccessorId: 1585758120000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Accessories", + yAccessorId: 1, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760700000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + { + xAccessorId: 1585761120000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + ], + columns: [ + { + id: 'xAccessorId', + name: 'order_date per minute', + meta: { + type: 'date', + field: 'order_date', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + type: 'date_histogram', + appliedTimeRange: { + from: '2020-04-01T16:14:16.246Z', + to: '2020-04-01T17:15:41.263Z', + }, + params: { field: 'order_date', - source: 'esaggs', - index: 'indexPatternId', - sourceParams: { - indexPatternId: 'indexPatternId', - type: 'date_histogram', - appliedTimeRange: { - from: '2020-04-01T16:14:16.246Z', - to: '2020-04-01T17:15:41.263Z', - }, - params: { - field: 'order_date', - timeRange: { from: '2020-04-01T16:14:16.246Z', to: '2020-04-01T17:15:41.263Z' }, - useNormalizedEsInterval: true, - scaleMetricValues: false, - interval: '1m', - drop_partials: false, - min_doc_count: 0, - extended_bounds: {}, - }, - }, - params: { id: 'date', params: { pattern: 'HH:mm' } }, + timeRange: { from: '2020-04-01T16:14:16.246Z', to: '2020-04-01T17:15:41.263Z' }, + useNormalizedEsInterval: true, + scaleMetricValues: false, + interval: '1m', + drop_partials: false, + min_doc_count: 0, + extended_bounds: {}, }, }, - { - id: 'splitAccessorId', - name: 'Top values of category.keyword', - meta: { - type: 'string', + params: { id: 'date', params: { pattern: 'HH:mm' } }, + }, + }, + { + id: 'splitAccessorId', + name: 'Top values of category.keyword', + meta: { + type: 'string', + field: 'category.keyword', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + type: 'terms', + params: { field: 'category.keyword', - source: 'esaggs', - index: 'indexPatternId', - sourceParams: { - indexPatternId: 'indexPatternId', - type: 'terms', - params: { - field: 'category.keyword', - orderBy: 'yAccessorId', - order: 'desc', - size: 3, - otherBucket: false, - otherBucketLabel: 'Other', - missingBucket: false, - missingBucketLabel: 'Missing', - }, - }, - params: { - id: 'terms', - params: { - id: 'string', - otherBucketLabel: 'Other', - missingBucketLabel: 'Missing', - parsedUrl: { - origin: 'http://localhost:5601', - pathname: '/jiy/app/kibana', - basePath: '/jiy', - }, - }, - }, + orderBy: 'yAccessorId', + order: 'desc', + size: 3, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', }, }, - { - id: 'yAccessorId', - name: 'Count of records', - meta: { - type: 'number', - source: 'esaggs', - index: 'indexPatternId', - sourceParams: { - indexPatternId: 'indexPatternId', - params: {}, + params: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + parsedUrl: { + origin: 'http://localhost:5601', + pathname: '/jiy/app/kibana', + basePath: '/jiy', }, - params: { id: 'number' }, }, }, - ], + }, + }, + { + id: 'yAccessorId', + name: 'Count of records', + meta: { + type: 'number', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + params: {}, + }, + params: { id: 'number' }, + }, }, - }, - dateRange: { - fromDate: new Date('2020-04-01T16:14:16.246Z'), - toDate: new Date('2020-04-01T17:15:41.263Z'), - }, + ], }; export const dateHistogramLayer: DataLayerConfig = { @@ -174,14 +164,13 @@ export const dateHistogramLayer: DataLayerConfig = { layerType: LayerTypes.DATA, hide: false, xAccessor: 'xAccessorId', - yScaleType: 'linear', xScaleType: 'time', isHistogram: true, splitAccessor: 'splitAccessorId', seriesType: 'bar_stacked', accessors: ['yAccessorId'], palette: mockPaletteOutput, - table: dateHistogramData.tables.timeLayer, + table: dateHistogramData, }; export function sampleArgsWithReferenceLine(value: number = 150) { 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 3b11ee812da6f6d..0bc41100012dea3 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 @@ -305,6 +305,7 @@ exports[`XYChart component it renders area 1`] = ` } } legendPosition="top" + legendSize={130} onBrushEnd={[Function]} onElementClick={[Function]} onPointerUpdate={[Function]} @@ -425,7 +426,9 @@ exports[`XYChart component it renders area 1`] = ` }, ], Array [ - undefined, + Object { + "id": "string", + }, ], Array [ Object { @@ -763,7 +766,6 @@ exports[`XYChart component it renders area 1`] = ` "type": "dataLayer", "xAccessor": "c", "xScaleType": "ordinal", - "yScaleType": "linear", }, ] } @@ -804,6 +806,7 @@ exports[`XYChart component it renders area 1`] = ` }, "groupId": "left", "position": "left", + "scale": "linear", "series": Array [ Object { "accessor": "a", @@ -846,6 +849,7 @@ exports[`XYChart component it renders bar 1`] = ` } } legendPosition="top" + legendSize={130} onBrushEnd={[Function]} onElementClick={[Function]} onPointerUpdate={[Function]} @@ -966,7 +970,9 @@ exports[`XYChart component it renders bar 1`] = ` }, ], Array [ - undefined, + Object { + "id": "string", + }, ], Array [ Object { @@ -1304,7 +1310,6 @@ exports[`XYChart component it renders bar 1`] = ` "type": "dataLayer", "xAccessor": "c", "xScaleType": "ordinal", - "yScaleType": "linear", }, ] } @@ -1345,6 +1350,7 @@ exports[`XYChart component it renders bar 1`] = ` }, "groupId": "left", "position": "left", + "scale": "linear", "series": Array [ Object { "accessor": "a", @@ -1387,6 +1393,7 @@ exports[`XYChart component it renders horizontal bar 1`] = ` } } legendPosition="top" + legendSize={130} onBrushEnd={[Function]} onElementClick={[Function]} onPointerUpdate={[Function]} @@ -1507,7 +1514,9 @@ exports[`XYChart component it renders horizontal bar 1`] = ` }, ], Array [ - undefined, + Object { + "id": "string", + }, ], Array [ Object { @@ -1845,7 +1854,6 @@ exports[`XYChart component it renders horizontal bar 1`] = ` "type": "dataLayer", "xAccessor": "c", "xScaleType": "ordinal", - "yScaleType": "linear", }, ] } @@ -1886,6 +1894,7 @@ exports[`XYChart component it renders horizontal bar 1`] = ` }, "groupId": "left", "position": "bottom", + "scale": "linear", "series": Array [ Object { "accessor": "a", @@ -1928,6 +1937,7 @@ exports[`XYChart component it renders line 1`] = ` } } legendPosition="top" + legendSize={130} onBrushEnd={[Function]} onElementClick={[Function]} onPointerUpdate={[Function]} @@ -2048,7 +2058,9 @@ exports[`XYChart component it renders line 1`] = ` }, ], Array [ - undefined, + Object { + "id": "string", + }, ], Array [ Object { @@ -2386,7 +2398,6 @@ exports[`XYChart component it renders line 1`] = ` "type": "dataLayer", "xAccessor": "c", "xScaleType": "ordinal", - "yScaleType": "linear", }, ] } @@ -2427,6 +2438,7 @@ exports[`XYChart component it renders line 1`] = ` }, "groupId": "left", "position": "left", + "scale": "linear", "series": Array [ Object { "accessor": "a", @@ -2469,6 +2481,7 @@ exports[`XYChart component it renders stacked area 1`] = ` } } legendPosition="top" + legendSize={130} onBrushEnd={[Function]} onElementClick={[Function]} onPointerUpdate={[Function]} @@ -2589,7 +2602,9 @@ exports[`XYChart component it renders stacked area 1`] = ` }, ], Array [ - undefined, + Object { + "id": "string", + }, ], Array [ Object { @@ -2927,7 +2942,6 @@ exports[`XYChart component it renders stacked area 1`] = ` "type": "dataLayer", "xAccessor": "c", "xScaleType": "ordinal", - "yScaleType": "linear", }, ] } @@ -2968,6 +2982,7 @@ exports[`XYChart component it renders stacked area 1`] = ` }, "groupId": "left", "position": "left", + "scale": "linear", "series": Array [ Object { "accessor": "a", @@ -3010,6 +3025,7 @@ exports[`XYChart component it renders stacked bar 1`] = ` } } legendPosition="top" + legendSize={130} onBrushEnd={[Function]} onElementClick={[Function]} onPointerUpdate={[Function]} @@ -3130,7 +3146,9 @@ exports[`XYChart component it renders stacked bar 1`] = ` }, ], Array [ - undefined, + Object { + "id": "string", + }, ], Array [ Object { @@ -3468,7 +3486,6 @@ exports[`XYChart component it renders stacked bar 1`] = ` "type": "dataLayer", "xAccessor": "c", "xScaleType": "ordinal", - "yScaleType": "linear", }, ] } @@ -3509,6 +3526,7 @@ exports[`XYChart component it renders stacked bar 1`] = ` }, "groupId": "left", "position": "left", + "scale": "linear", "series": Array [ Object { "accessor": "a", @@ -3551,6 +3569,7 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = ` } } legendPosition="top" + legendSize={130} onBrushEnd={[Function]} onElementClick={[Function]} onPointerUpdate={[Function]} @@ -3671,7 +3690,9 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = ` }, ], Array [ - undefined, + Object { + "id": "string", + }, ], Array [ Object { @@ -4009,7 +4030,6 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = ` "type": "dataLayer", "xAccessor": "c", "xScaleType": "ordinal", - "yScaleType": "linear", }, ] } @@ -4050,6 +4070,2402 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = ` }, "groupId": "left", "position": "bottom", + "scale": "linear", + "series": Array [ + Object { + "accessor": "a", + "layer": "first", + }, + Object { + "accessor": "b", + "layer": "first", + }, + ], + }, + ] + } + /> + +`; + +exports[`XYChart component split chart should render split chart if both, splitRowAccessor and splitColumnAccessor are specified 1`] = ` + + + + + + + + +`; + +exports[`XYChart component split chart should render split chart if splitColumnAccessor is specified 1`] = ` + + + + + + + + +`; + +exports[`XYChart component split chart should render split chart if splitRowAccessor is specified 1`] = ` + + + + + + + = ({ <> {layers.flatMap((layer) => layer.accessors.map((accessor, accessorIndex) => { - const { seriesType, columnToLabel, layerId } = layer; + const { seriesType, columnToLabel, layerId, table } = layer; + const yColumnId = getAccessorByDimension(accessor, table.columns); const columnToLabelMap: Record = columnToLabel ? JSON.parse(columnToLabel) : {}; @@ -84,12 +87,12 @@ export const DataLayers: FC = ({ const isPercentage = seriesType.includes('percentage'); const yAxis = yAxesConfiguration.find((axisConfiguration) => - axisConfiguration.series.find((currentSeries) => currentSeries.accessor === accessor) + axisConfiguration.series.find((currentSeries) => currentSeries.accessor === yColumnId) ); const seriesProps = getSeriesProps({ layer, - accessor, + accessor: yColumnId, chartHasMoreThanOneBarSeries, colorAssignments, formatFactory, diff --git a/src/plugins/chart_expressions/expression_xy/public/components/legend_action.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/legend_action.test.tsx index 7c60a6a3a5769e7..8289d605aa913d9 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/legend_action.test.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/legend_action.test.tsx @@ -7,151 +7,150 @@ */ import React from 'react'; +import { Datatable } from '@kbn/expressions-plugin/common'; import { LegendActionProps, SeriesIdentifier } from '@elastic/charts'; import { EuiPopover } from '@elastic/eui'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { ComponentType, ReactWrapper } from 'enzyme'; -import type { DataLayerConfig, LensMultiTable } from '../../common'; +import type { DataLayerConfig } from '../../common'; import { LayerTypes } from '../../common/constants'; import { getLegendAction } from './legend_action'; import { LegendActionPopover } from './legend_action_popover'; import { mockPaletteOutput } from '../../common/__mocks__'; -const tables = { - first: { - type: 'datatable', - rows: [ - { - xAccessorId: 1585758120000, - splitAccessorId: "Men's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585758360000, - splitAccessorId: "Women's Accessories", - yAccessorId: 1, - }, - { - xAccessorId: 1585758360000, - splitAccessorId: "Women's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585759380000, - splitAccessorId: "Men's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585759380000, - splitAccessorId: "Men's Shoes", - yAccessorId: 1, - }, - { - xAccessorId: 1585759380000, - splitAccessorId: "Women's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585760700000, - splitAccessorId: "Men's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585760760000, - splitAccessorId: "Men's Clothing", - yAccessorId: 1, - }, - { - xAccessorId: 1585760760000, - splitAccessorId: "Men's Shoes", - yAccessorId: 1, - }, - { - xAccessorId: 1585761120000, - splitAccessorId: "Men's Shoes", - yAccessorId: 1, - }, - ], - columns: [ - { - id: 'xAccessorId', - name: 'order_date per minute', - meta: { - type: 'date', - field: 'order_date', - source: 'esaggs', - index: 'indexPatternId', - sourceParams: { - indexPatternId: 'indexPatternId', - type: 'date_histogram', - params: { - field: 'order_date', - timeRange: { from: '2020-04-01T16:14:16.246Z', to: '2020-04-01T17:15:41.263Z' }, - useNormalizedEsInterval: true, - scaleMetricValues: false, - interval: '1m', - drop_partials: false, - min_doc_count: 0, - extended_bounds: {}, - }, +const table: Datatable = { + type: 'datatable', + rows: [ + { + xAccessorId: 1585758120000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Accessories", + yAccessorId: 1, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760700000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + { + xAccessorId: 1585761120000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + }, + ], + columns: [ + { + id: 'xAccessorId', + name: 'order_date per minute', + meta: { + type: 'date', + field: 'order_date', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + type: 'date_histogram', + params: { + field: 'order_date', + timeRange: { from: '2020-04-01T16:14:16.246Z', to: '2020-04-01T17:15:41.263Z' }, + useNormalizedEsInterval: true, + scaleMetricValues: false, + interval: '1m', + drop_partials: false, + min_doc_count: 0, + extended_bounds: {}, }, - params: { id: 'date', params: { pattern: 'HH:mm' } }, }, + params: { id: 'date', params: { pattern: 'HH:mm' } }, }, - { - id: 'splitAccessorId', - name: 'Top values of category.keyword', - meta: { - type: 'string', - field: 'category.keyword', - source: 'esaggs', - index: 'indexPatternId', - sourceParams: { - indexPatternId: 'indexPatternId', - type: 'terms', - params: { - field: 'category.keyword', - orderBy: 'yAccessorId', - order: 'desc', - size: 3, - otherBucket: false, - otherBucketLabel: 'Other', - missingBucket: false, - missingBucketLabel: 'Missing', - }, + }, + { + id: 'splitAccessorId', + name: 'Top values of category.keyword', + meta: { + type: 'string', + field: 'category.keyword', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + type: 'terms', + params: { + field: 'category.keyword', + orderBy: 'yAccessorId', + order: 'desc', + size: 3, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', }, + }, + params: { + id: 'terms', params: { - id: 'terms', - params: { - id: 'string', - otherBucketLabel: 'Other', - missingBucketLabel: 'Missing', - parsedUrl: { - origin: 'http://localhost:5601', - pathname: '/jiy/app/kibana', - basePath: '/jiy', - }, + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + parsedUrl: { + origin: 'http://localhost:5601', + pathname: '/jiy/app/kibana', + basePath: '/jiy', }, }, }, }, - { - id: 'yAccessorId', - name: 'Count of records', - meta: { - type: 'number', - source: 'esaggs', - index: 'indexPatternId', - sourceParams: { - indexPatternId: 'indexPatternId', - params: {}, - }, - params: { id: 'number' }, + }, + { + id: 'yAccessorId', + name: 'Count of records', + meta: { + type: 'number', + source: 'esaggs', + index: 'indexPatternId', + sourceParams: { + indexPatternId: 'indexPatternId', + params: {}, }, + params: { id: 'number' }, }, - ], - }, -} as LensMultiTable['tables']; + }, + ], +}; const sampleLayer: DataLayerConfig = { layerId: 'first', @@ -163,10 +162,9 @@ const sampleLayer: DataLayerConfig = { splitAccessor: 'splitAccessorId', columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, - table: tables.first, + table, }; describe('getLegendAction', function () { @@ -228,7 +226,7 @@ describe('getLegendAction', function () { { column: 1, row: 1, - table: tables.first, + table, value: "Women's Accessories", }, ], diff --git a/src/plugins/chart_expressions/expression_xy/public/components/legend_action.tsx b/src/plugins/chart_expressions/expression_xy/public/components/legend_action.tsx index da1939f223649ea..68e5b895599338c 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/legend_action.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/legend_action.tsx @@ -8,11 +8,12 @@ import React from 'react'; import type { LegendAction, XYChartSeriesIdentifier } from '@elastic/charts'; +import { getAccessorByDimension } from '@kbn/visualizations-plugin/common/utils'; import type { FilterEvent } from '../types'; import type { CommonXYDataLayerConfig } from '../../common'; import type { FormatFactory } from '../types'; import { LegendActionPopover } from './legend_action_popover'; -import { DatatablesWithFormatInfo } from '../helpers'; +import { DatatablesWithFormatInfo, getFormat } from '../helpers'; export const getLegendAction = ( dataLayers: CommonXYDataLayerConfig[], @@ -23,7 +24,11 @@ export const getLegendAction = ( React.memo(({ series: [xySeries] }) => { const series = xySeries as XYChartSeriesIdentifier; const layerIndex = dataLayers.findIndex((l) => - series.seriesKeys.some((key: string | number) => l.accessors.includes(key.toString())) + series.seriesKeys.some((key: string | number) => + l.accessors.some( + (accessor) => getAccessorByDimension(accessor, l.table.columns) === key.toString() + ) + ) ); if (layerIndex === -1) { @@ -36,11 +41,12 @@ export const getLegendAction = ( } const splitLabel = series.seriesKeys[0] as string; - const accessor = layer.splitAccessor; const { table } = layer; - const splitColumn = table.columns.find(({ id }) => id === layer.splitAccessor); - const formatter = formatFactory(splitColumn && splitColumn.meta?.params); + const accessor = getAccessorByDimension(layer.splitAccessor, table.columns); + const formatter = formatFactory( + accessor ? getFormat(table.columns, layer.splitAccessor) : undefined + ); const rowIndex = table.rows.findIndex((row) => { if (formattedDatatables[layer.layerId]?.formattedColumns[accessor]) { diff --git a/src/plugins/chart_expressions/expression_xy/public/components/split_chart.tsx b/src/plugins/chart_expressions/expression_xy/public/components/split_chart.tsx new file mode 100644 index 000000000000000..f0b2f7f66a00c65 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/components/split_chart.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 React, { useCallback } from 'react'; +import { GroupBy, SmallMultiples, Predicate } from '@elastic/charts'; +import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common'; +import { getColumnByAccessor, getFormatByAccessor } from '@kbn/visualizations-plugin/common/utils'; +import { Datatable } from '@kbn/expressions-plugin/public'; +import { FormatFactory } from '../types'; + +interface SplitChartProps { + splitColumnAccessor?: ExpressionValueVisDimension | string; + splitRowAccessor?: ExpressionValueVisDimension | string; + columns: Datatable['columns']; + formatFactory: FormatFactory; +} + +const SPLIT_COLUMN = '__split_column__'; +const SPLIT_ROW = '__split_row__'; + +export const SplitChart = ({ + splitColumnAccessor, + splitRowAccessor, + columns, + formatFactory, +}: SplitChartProps) => { + const format = useCallback( + (value: unknown, accessor: ExpressionValueVisDimension | string) => { + const formatParams = getFormatByAccessor(accessor, columns); + const formatter = formatParams ? formatFactory(formatParams) : formatFactory(); + return formatter.convert(value); + }, + [columns, formatFactory] + ); + + const getData = useCallback( + (datum: Record, accessor: ExpressionValueVisDimension | string) => { + const splitColumn = getColumnByAccessor(accessor, columns); + return datum[splitColumn!.id]; + }, + [columns] + ); + + return splitColumnAccessor || splitRowAccessor ? ( + <> + {splitColumnAccessor && ( + getData(datum, splitColumnAccessor)} + sort={Predicate.DataIndex} + format={(value) => format(value, splitColumnAccessor)} + /> + )} + {splitRowAccessor && ( + getData(datum, splitRowAccessor)} + sort={Predicate.DataIndex} + format={(value) => format(value, splitRowAccessor)} + /> + )} + + + ) : null; +}; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/x_domain.tsx b/src/plugins/chart_expressions/expression_xy/public/components/x_domain.tsx index 78b6ef91926a8cd..10b2140eae6a104 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/x_domain.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/x_domain.tsx @@ -6,11 +6,15 @@ * Side Public License, v 1. */ -import { uniq } from 'lodash'; +import { isUndefined, uniq } from 'lodash'; import React from 'react'; import moment from 'moment'; import { Endzones } from '@kbn/charts-plugin/public'; import { search } from '@kbn/data-plugin/public'; +import { + getAccessorByDimension, + getColumnByAccessor, +} from '@kbn/visualizations-plugin/common/utils'; import type { CommonXYDataLayerConfig } from '../../common'; export interface XDomain { @@ -22,7 +26,7 @@ export interface XDomain { export const getAppliedTimeRange = (layers: CommonXYDataLayerConfig[]) => { return layers .map(({ xAccessor, table }) => { - const xColumn = table.columns.find((col) => col.id === xAccessor); + const xColumn = xAccessor ? getColumnByAccessor(xAccessor, table.columns) : null; const timeRange = xColumn && search.aggs.getDateHistogramMetaDataByDatatableColumn(xColumn)?.timeRange; if (timeRange) { @@ -57,9 +61,11 @@ export const getXDomain = ( if (isHistogram && isFullyQualified(baseDomain)) { const xValues = uniq( layers - .flatMap(({ table, xAccessor }) => - table.rows.map((row) => row[xAccessor!].valueOf()) - ) + .flatMap(({ table, xAccessor }) => { + const accessor = xAccessor && getAccessorByDimension(xAccessor, table.columns); + return table.rows.map((row) => accessor && row[accessor] && row[accessor].valueOf()); + }) + .filter((v) => !isUndefined(v)) .sort() ); 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 de67e814d5b78fe..911dbeea104169f 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 @@ -15,6 +15,7 @@ import { BarSeries, Fit, GeometryValue, + GroupBy, HorizontalAlignment, LayoutDirection, LineAnnotation, @@ -24,6 +25,7 @@ import { ScaleType, SeriesNameFn, Settings, + SmallMultiples, VerticalAlignment, XYChartSeriesIdentifier, } from '@elastic/charts'; @@ -57,6 +59,8 @@ import { } from '../../common/types'; import { DataLayers } from './data_layers'; import { Annotations } from './annotations'; +import { SplitChart } from './split_chart'; +import { LegendSize } from '@kbn/visualizations-plugin/common'; const onClickValue = jest.fn(); const onSelectRange = jest.fn(); @@ -153,7 +157,6 @@ describe('XYChart component', () => { splitAccessor: 'd', columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', xScaleType: 'time', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, table: { @@ -250,7 +253,6 @@ describe('XYChart component', () => { splitAccessor: 'd', columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', xScaleType: 'time', - yScaleType: 'linear', isHistogram: true, palette: mockPaletteOutput, table: data, @@ -756,6 +758,29 @@ describe('XYChart component', () => { expect(component.find(EmptyPlaceholder).prop('icon')).toBeDefined(); }); + test('it renders empty placeholder for no results with references layer', () => { + const { data, args } = sampleArgsWithReferenceLine(); + const emptyDataLayers = args.layers.map((layer) => { + if (layer.type === 'dataLayer') { + return { ...layer, table: { ...data, rows: [] } }; + } else { + return layer; + } + }); + const component = shallow( + + ); + + expect(component.find(BarSeries)).toHaveLength(0); + expect(component.find(EmptyPlaceholder).prop('icon')).toBeDefined(); + }); + test('onBrushEnd returns correct context data for date histogram data', () => { const { args } = sampleArgs(); @@ -772,7 +797,7 @@ describe('XYChart component', () => { expect(onSelectRange).toHaveBeenCalledWith({ column: 0, - table: dateHistogramData.tables.timeLayer, + table: dateHistogramData, range: [1585757732783, 1585758880838], }); }); @@ -820,7 +845,6 @@ describe('XYChart component', () => { layerType: LayerTypes.DATA, hide: false, xAccessor: 'xAccessorId', - yScaleType: 'linear', xScaleType: 'linear', isHistogram: true, seriesType: 'bar_stacked', @@ -896,7 +920,6 @@ describe('XYChart component', () => { isHistogram: true, seriesType: 'bar_stacked', xAccessor: 'b', - yScaleType: 'linear', xScaleType: 'time', splitAccessor: 'b', accessors: ['d'], @@ -968,7 +991,7 @@ describe('XYChart component', () => { { column: 0, row: 0, - table: dateHistogramData.tables.timeLayer, + table: dateHistogramData, value: 1585758120000, }, ], @@ -1018,7 +1041,6 @@ describe('XYChart component', () => { layerType: LayerTypes.DATA, hide: false, xAccessor: 'xAccessorId', - yScaleType: 'linear', xScaleType: 'linear', isHistogram: true, seriesType: 'bar_stacked', @@ -1098,7 +1120,6 @@ describe('XYChart component', () => { accessors: ['a', 'b'], columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, table: data, @@ -1157,7 +1178,6 @@ describe('XYChart component', () => { accessors: ['a', 'b'], columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, table: newData, @@ -1188,7 +1208,6 @@ describe('XYChart component', () => { accessors: ['a', 'b'], columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, table: data, @@ -1871,7 +1890,7 @@ describe('XYChart component', () => { {...defaultProps} args={{ ...args, - layers: [{ ...(args.layers[0] as DataLayerConfig), yScaleType: 'sqrt' }], + yLeftScale: 'sqrt', }} /> ); @@ -2080,6 +2099,8 @@ describe('XYChart component', () => { xTitle: '', yTitle: '', yRightTitle: '', + yLeftScale: 'linear', + yRightScale: 'linear', legend: { type: 'legendConfig', isVisible: false, position: Position.Top }, valueLabels: 'hide', tickLabelsVisibilitySettings: { @@ -2119,7 +2140,6 @@ describe('XYChart component', () => { splitAccessor: 'b', columnToLabel: '', xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, table: data1, @@ -2134,7 +2154,6 @@ describe('XYChart component', () => { splitAccessor: 'b', columnToLabel: '', xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, table: data2, @@ -2197,6 +2216,8 @@ describe('XYChart component', () => { mode: 'full', type: 'axisExtentConfig', }, + yLeftScale: 'linear', + yRightScale: 'linear', layers: [ { layerId: 'first', @@ -2208,7 +2229,6 @@ describe('XYChart component', () => { splitAccessor: 'b', columnToLabel: '', xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, table: data, @@ -2269,6 +2289,8 @@ describe('XYChart component', () => { mode: 'full', type: 'axisExtentConfig', }, + yLeftScale: 'linear', + yRightScale: 'linear', layers: [ { layerId: 'first', @@ -2280,7 +2302,6 @@ describe('XYChart component', () => { splitAccessor: 'b', columnToLabel: '', xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, table: data, @@ -2377,6 +2398,37 @@ describe('XYChart component', () => { expect(component.find(Settings).prop('legendPosition')).toEqual('top'); }); + it('computes correct legend sizes', () => { + const { args } = sampleArgs(); + + const component = shallow( + + ); + expect(component.find(Settings).prop('legendSize')).toEqual(80); + + component.setProps({ + args: { + ...args, + legend: { ...args.legend, legendSize: LegendSize.AUTO }, + }, + }); + expect(component.find(Settings).prop('legendSize')).toBeUndefined(); + + component.setProps({ + args: { + ...args, + legend: { ...args.legend, legendSize: undefined }, + }, + }); + expect(component.find(Settings).prop('legendSize')).toEqual(130); + }); + test('it should apply the fitting function to all non-bar series', () => { const data: Datatable = createSampleDatatableWithRows([ { a: 1, b: 2, c: 'I', d: 'Foo' }, @@ -2499,7 +2551,6 @@ describe('XYChart component', () => { xAccessor: 'c', accessors: ['a', 'b'], xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: mockPaletteOutput, table: data, @@ -2666,4 +2717,94 @@ describe('XYChart component', () => { expect(rectAnnotations.length).toEqual(1); }); }); + + describe('split chart', () => { + const SPLIT_COLUMN = '__split_column__'; + const SPLIT_ROW = '__split_row__'; + + it('should render split chart if splitRowAccessor is specified', () => { + const { args } = sampleArgs(); + const splitRowAccessor = 'b'; + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + + const splitChart = component.find(SplitChart); + + expect(splitChart.prop('splitRowAccessor')).toEqual(splitRowAccessor); + + const groupBy = splitChart.dive().find(GroupBy); + const smallMultiples = splitChart.dive().find(SmallMultiples); + + expect(groupBy.at(0).prop('id')).toEqual(SPLIT_ROW); + expect(smallMultiples.prop('splitHorizontally')).toEqual(SPLIT_ROW); + }); + + it('should render split chart if splitColumnAccessor is specified', () => { + const { args } = sampleArgs(); + const splitColumnAccessor = 'b'; + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + + const splitChart = component.find(SplitChart); + + expect(splitChart.prop('splitColumnAccessor')).toEqual(splitColumnAccessor); + + const groupBy = splitChart.dive().find(GroupBy); + const smallMultiples = splitChart.dive().find(SmallMultiples); + + expect(groupBy.at(0).prop('id')).toEqual(SPLIT_COLUMN); + expect(smallMultiples.prop('splitVertically')).toEqual(SPLIT_COLUMN); + }); + + it('should render split chart if both, splitRowAccessor and splitColumnAccessor are specified', () => { + const { args } = sampleArgs(); + const splitColumnAccessor = 'b'; + const splitRowAccessor = 'c'; + + const component = shallow( + + ); + expect(component).toMatchSnapshot(); + + const splitChart = component.find(SplitChart); + + expect(splitChart.prop('splitRowAccessor')).toEqual(splitRowAccessor); + expect(splitChart.prop('splitColumnAccessor')).toEqual(splitColumnAccessor); + + const groupBy = splitChart.dive().find(GroupBy); + const smallMultiples = splitChart.dive().find(SmallMultiples); + + expect(groupBy.at(0).prop('id')).toEqual(SPLIT_COLUMN); + expect(groupBy.at(1).prop('id')).toEqual(SPLIT_ROW); + + expect(smallMultiples.prop('splitVertically')).toEqual(SPLIT_COLUMN); + expect(smallMultiples.prop('splitHorizontally')).toEqual(SPLIT_ROW); + }); + }); }); 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 db653861a337e7a..7b31112c4b9ed6c 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 @@ -33,6 +33,14 @@ import { EmptyPlaceholder } from '@kbn/charts-plugin/public'; import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public'; import { ChartsPluginSetup, ChartsPluginStart, useActiveCursor } from '@kbn/charts-plugin/public'; import { MULTILAYER_TIME_AXIS_STYLE } from '@kbn/charts-plugin/common'; +import { + getAccessorByDimension, + getColumnByAccessor, +} from '@kbn/visualizations-plugin/common/utils'; +import { + DEFAULT_LEGEND_SIZE, + LegendSizeToPixels, +} from '@kbn/visualizations-plugin/common/constants'; import type { FilterEvent, BrushEvent, FormatFactory } from '../types'; import type { CommonXYDataLayerConfig, SeriesType, XYChartProps } from '../../common/types'; import { @@ -42,6 +50,7 @@ import { Series, getFormattedTablesByLayers, validateExtent, + getFormat, } from '../helpers'; import { getFilteredLayers, @@ -56,6 +65,7 @@ import { getLegendAction } from './legend_action'; import { ReferenceLineAnnotations, computeChartMargins } from './reference_lines'; import { visualizationDefinitions } from '../definitions'; import { CommonXYLayerConfig } from '../../common/types'; +import { SplitChart } from './split_chart'; import { Annotations, getAnnotationsGroupedByInterval, @@ -147,6 +157,10 @@ export function XYChart({ yLeftExtent, yRightExtent, valuesInLegend, + yLeftScale, + yRightScale, + splitColumnAccessor, + splitRowAccessor, } = args; const chartRef = useRef(null); const chartTheme = chartsThemeService.useChartsTheme(); @@ -168,7 +182,7 @@ export function XYChart({ [dataLayers, formatFactory] ); - if (filteredLayers.length === 0) { + if (dataLayers.length === 0) { const icon: IconType = getIconForSeriesType( getDataLayers(layers)?.[0]?.seriesType || SeriesTypes.BAR ); @@ -176,9 +190,15 @@ export function XYChart({ } // use formatting hint of first x axis column to format ticks - const xAxisColumn = dataLayers[0]?.table.columns.find(({ id }) => id === dataLayers[0].xAccessor); - - const xAxisFormatter = formatFactory(xAxisColumn && xAxisColumn.meta?.params); + const xAxisColumn = dataLayers[0].xAccessor + ? getColumnByAccessor(dataLayers[0].xAccessor, dataLayers[0]?.table.columns) + : undefined; + + const xAxisFormatter = formatFactory( + dataLayers[0].xAccessor + ? getFormat(dataLayers[0].table.columns, dataLayers[0].xAccessor) + : undefined + ); // This is a safe formatter for the xAccessor that abstracts the knowledge of already formatted layers const safeXAccessorLabelRenderer = (value: unknown): string => @@ -192,7 +212,13 @@ export function XYChart({ filteredLayers.some((layer) => isDataLayer(layer) && layer.splitAccessor); const shouldRotate = isHorizontalChart(dataLayers); - const yAxesConfiguration = getAxesConfiguration(dataLayers, shouldRotate, formatFactory); + const yAxesConfiguration = getAxesConfiguration( + dataLayers, + shouldRotate, + formatFactory, + yLeftScale, + yRightScale + ); const xTitle = args.xTitle || (xAxisColumn && xAxisColumn.name); const axisTitlesVisibilitySettings = args.axisTitlesVisibilitySettings || { @@ -249,12 +275,14 @@ export function XYChart({ const annotationsLayers = getAnnotationsLayers(layers); const firstTable = dataLayers[0]?.table; - const xColumnId = firstTable.columns.find((col) => col.id === dataLayers[0]?.xAccessor)?.id; + const columnId = dataLayers[0]?.xAccessor + ? getColumnByAccessor(dataLayers[0]?.xAccessor, firstTable.columns)?.id + : null; const groupedLineAnnotations = getAnnotationsGroupedByInterval( annotationsLayers, minInterval, - xColumnId ? firstTable.rows[0]?.[xColumnId] : undefined, + columnId ? firstTable.rows[0]?.[columnId] : undefined, xAxisFormatter ); const rangeAnnotations = getRangeAnnotations(annotationsLayers); @@ -362,7 +390,11 @@ export function XYChart({ const xyGeometry = geometry as GeometryValue; const layerIndex = dataLayers.findIndex((l) => - xySeries.seriesKeys.some((key: string | number) => l.accessors.includes(key.toString())) + xySeries.seriesKeys.some((key: string | number) => + l.accessors.some( + (accessor) => getAccessorByDimension(accessor, l.table.columns) === key.toString() + ) + ) ); if (layerIndex === -1) { @@ -372,48 +404,53 @@ export function XYChart({ const layer = dataLayers[layerIndex]; const { table } = layer; - const xColumn = table.columns.find((col) => col.id === layer.xAccessor); + const xColumn = layer.xAccessor && getColumnByAccessor(layer.xAccessor, table.columns); + const xAccessor = layer.xAccessor + ? getAccessorByDimension(layer.xAccessor, table.columns) + : undefined; const currentXFormatter = - layer.xAccessor && - formattedDatatables[layer.layerId]?.formattedColumns[layer.xAccessor] && - xColumn - ? formatFactory(xColumn.meta.params) + xAccessor && formattedDatatables[layer.layerId]?.formattedColumns[xAccessor] && xColumn + ? formatFactory(layer.xAccessor ? getFormat(table.columns, layer.xAccessor) : undefined) : xAxisFormatter; const rowIndex = table.rows.findIndex((row) => { - if (layer.xAccessor) { - if (formattedDatatables[layer.layerId]?.formattedColumns[layer.xAccessor]) { + if (xAccessor) { + if (formattedDatatables[layer.layerId]?.formattedColumns[xAccessor]) { // stringify the value to compare with the chart value - return currentXFormatter.convert(row[layer.xAccessor]) === xyGeometry.x; + return currentXFormatter.convert(row[xAccessor]) === xyGeometry.x; } - return row[layer.xAccessor] === xyGeometry.x; + return row[xAccessor] === xyGeometry.x; } }); const points = [ { row: rowIndex, - column: table.columns.findIndex((col) => col.id === layer.xAccessor), - value: layer.xAccessor ? table.rows[rowIndex][layer.xAccessor] : xyGeometry.x, + column: table.columns.findIndex((col) => col.id === xAccessor), + value: xAccessor ? table.rows[rowIndex][xAccessor] : xyGeometry.x, }, ]; if (xySeries.seriesKeys.length > 1) { const pointValue = xySeries.seriesKeys[0]; + const splitAccessor = layer.splitAccessor + ? getAccessorByDimension(layer.splitAccessor, table.columns) + : undefined; - const splitColumn = table.columns.find(({ id }) => id === layer.splitAccessor); - const splitFormatter = formatFactory(splitColumn && splitColumn.meta?.params); + const splitFormatter = formatFactory( + layer.splitAccessor ? getFormat(table.columns, layer.splitAccessor) : undefined + ); points.push({ row: table.rows.findIndex((row) => { - if (layer.splitAccessor) { - if (formattedDatatables[layer.layerId]?.formattedColumns[layer.splitAccessor]) { - return splitFormatter.convert(row[layer.splitAccessor]) === pointValue; + if (splitAccessor) { + if (formattedDatatables[layer.layerId]?.formattedColumns[splitAccessor]) { + return splitFormatter.convert(row[splitAccessor]) === pointValue; } - return row[layer.splitAccessor] === pointValue; + return row[splitAccessor] === pointValue; } }), - column: table.columns.findIndex((col) => col.id === layer.splitAccessor), + column: table.columns.findIndex((col) => col.id === splitAccessor), value: pointValue, }); } @@ -433,8 +470,9 @@ export function XYChart({ } const { table } = dataLayers[0]; - - const xAxisColumnIndex = table.columns.findIndex((el) => el.id === dataLayers[0].xAccessor); + const xAccessor = + dataLayers[0].xAccessor && getAccessorByDimension(dataLayers[0].xAccessor, table.columns); + const xAxisColumnIndex = table.columns.findIndex((el) => el.id === xAccessor); const context: BrushEvent['data'] = { range: [min, max], table, column: xAxisColumnIndex }; onSelectRange(context); @@ -492,6 +530,9 @@ export function XYChart({ : undefined, }, }; + const isSplitChart = splitColumnAccessor || splitRowAccessor; + const splitTable = isSplitChart ? dataLayers[0].table : undefined; + return ( - + {isSplitChart && splitTable && ( + + )} {yAxesConfiguration.map((axis) => { return ( { splitAccessor: 'd', columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', xScaleType: 'ordinal', - yScaleType: 'linear', isHistogram: false, palette: { type: 'palette', name: 'default' }, table: tables.first, diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/axes_configuration.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/axes_configuration.ts index 65f5441d67226b8..89dc87ae5383b06 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/axes_configuration.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/axes_configuration.ts @@ -7,9 +7,17 @@ */ import type { IFieldFormat, SerializedFieldFormat } from '@kbn/field-formats-plugin/common'; +import { getAccessorByDimension } from '@kbn/visualizations-plugin/common/utils'; import { FormatFactory } from '../types'; -import { AxisExtentConfig, CommonXYDataLayerConfig, ExtendedYConfig, YConfig } from '../../common'; +import { + AxisExtentConfig, + CommonXYDataLayerConfig, + ExtendedYConfig, + YConfig, + YScaleType, +} from '../../common'; import { isDataLayer } from './visualization'; +import { getFormat } from './format'; export interface Series { layer: string; @@ -25,6 +33,7 @@ export type GroupsConfiguration = Array<{ position: 'left' | 'right' | 'bottom' | 'top'; formatter?: IFieldFormat; series: Series[]; + scale?: YScaleType; }>; export function isFormatterCompatible( @@ -51,10 +60,12 @@ export function groupAxesByType(layers: CommonXYDataLayerConfig[]) { const { table } = layer; layer.accessors.forEach((accessor) => { const yConfig: Array | undefined = layer.yConfig; + const yAccessor = getAccessorByDimension(accessor, table?.columns || []); const mode = - yConfig?.find((yAxisConfig) => yAxisConfig.forAccessor === accessor)?.axisMode || 'auto'; - let formatter: SerializedFieldFormat = table.columns?.find((column) => column.id === accessor) - ?.meta?.params || { id: 'number' }; + yConfig?.find((yAxisConfig) => yAxisConfig.forAccessor === yAccessor)?.axisMode || 'auto'; + let formatter: SerializedFieldFormat = getFormat(table.columns, accessor) || { + id: 'number', + }; if ( isDataLayer(layer) && layer.seriesType.includes('percentage') && @@ -69,7 +80,7 @@ export function groupAxesByType(layers: CommonXYDataLayerConfig[]) { } series[mode].push({ layer: layer.layerId, - accessor, + accessor: yAccessor, fieldFormat: formatter, }); }); @@ -106,7 +117,9 @@ export function groupAxesByType(layers: CommonXYDataLayerConfig[]) { export function getAxesConfiguration( layers: CommonXYDataLayerConfig[], shouldRotate: boolean, - formatFactory?: FormatFactory + formatFactory?: FormatFactory, + yLeftScale?: YScaleType, + yRightScale?: YScaleType ): GroupsConfiguration { const series = groupAxesByType(layers); @@ -118,6 +131,7 @@ export function getAxesConfiguration( position: shouldRotate ? 'bottom' : 'left', formatter: formatFactory?.(series.left[0].fieldFormat), series: series.left.map(({ fieldFormat, ...currentSeries }) => currentSeries), + scale: yLeftScale, }); } @@ -127,6 +141,7 @@ export function getAxesConfiguration( position: shouldRotate ? 'top' : 'right', formatter: formatFactory?.(series.right[0].fieldFormat), series: series.right.map(({ fieldFormat, ...currentSeries }) => currentSeries), + scale: yRightScale, }); } diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/color_assignment.test.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/color_assignment.test.ts index 8b1bdeeadb83421..836d7209a6a5b68 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/color_assignment.test.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/color_assignment.test.ts @@ -52,7 +52,6 @@ describe('color_assignment', () => { { layerId: 'first', type: 'dataLayer', - yScaleType: 'linear', xScaleType: 'linear', isHistogram: true, seriesType: 'bar', @@ -65,7 +64,6 @@ describe('color_assignment', () => { { layerId: 'second', type: 'dataLayer', - yScaleType: 'linear', xScaleType: 'linear', isHistogram: true, seriesType: 'bar', diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/color_assignment.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/color_assignment.ts index e94d22471aba9c0..0b7f8d8b08f22cd 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/color_assignment.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/color_assignment.ts @@ -8,9 +8,11 @@ import { uniq, mapValues } from 'lodash'; import { euiLightVars } from '@kbn/ui-theme'; +import { getAccessorByDimension } from '@kbn/visualizations-plugin/common/utils'; import { FormatFactory } from '../types'; import { isDataLayer } from './visualization'; import { CommonXYDataLayerConfig, CommonXYLayerConfig } from '../../common'; +import { getFormat } from './format'; const isPrimitive = (value: unknown): boolean => value != null && typeof value !== 'object'; @@ -47,9 +49,10 @@ export function getColorAssignments( if (!layer.splitAccessor) { return { numberOfSeries: layer.accessors.length, splits: [] }; } - const splitAccessor = layer.splitAccessor; + const splitAccessor = getAccessorByDimension(layer.splitAccessor, layer.table.columns); const column = layer.table.columns?.find(({ id }) => id === splitAccessor); - const columnFormatter = column && formatFactory(column.meta.params); + const columnFormatter = + column && formatFactory(getFormat(layer.table.columns, layer.splitAccessor)); const splits = !column || !layer.table ? [] @@ -87,7 +90,9 @@ export function getColorAssignments( (sortedLayer.splitAccessor && splitRank !== -1 ? splitRank * sortedLayer.accessors.length : 0) + - sortedLayer.accessors.indexOf(yAccessor) + sortedLayer.accessors.findIndex( + (accessor) => getAccessorByDimension(accessor, sortedLayer.table.columns) === yAccessor + ) ); }, }; 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 07af8a3c408c240..c2a7c847e150b9e 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 @@ -24,12 +24,18 @@ import { SerializedFieldFormat, } from '@kbn/field-formats-plugin/common'; import { Datatable } from '@kbn/expressions-plugin'; +import { + getFormatByAccessor, + getAccessorByDimension, +} from '@kbn/visualizations-plugin/common/utils'; +import type { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common/expression_functions'; import { PaletteRegistry, SeriesLayer } from '@kbn/coloring'; import { CommonXYDataLayerConfig, XScaleType } from '../../common'; import { FormatFactory } from '../types'; import { getSeriesColor } from './state'; import { ColorAssignments } from './color_assignment'; import { GroupsConfiguration } from './axes_configuration'; +import { getFormat } from './format'; type SeriesSpec = LineSeriesProps & BarSeriesProps & AreaSeriesProps; @@ -52,7 +58,8 @@ type GetSeriesPropsFn = (config: { type GetSeriesNameFn = ( data: XYChartSeriesIdentifier, config: { - layer: CommonXYDataLayerConfig; + splitColumnId?: string; + accessorsCount: number; splitHint: SerializedFieldFormat | undefined; splitFormatter: FieldFormat; alreadyFormattedColumns: Record; @@ -111,11 +118,21 @@ export const getFormattedRow = ( export const getFormattedTable = ( table: Datatable, formatFactory: FormatFactory, - xAccessor: string | undefined, + xAccessor: string | ExpressionValueVisDimension | undefined, + accessors: Array, xScaleType: XScaleType ): { table: Datatable; formattedColumns: Record } => { const columnsFormatters = table.columns.reduce>( - (formatters, { id, meta }) => ({ ...formatters, [id]: formatFactory(meta.params) }), + (formatters, { id, meta }) => { + const accessor: string | ExpressionValueVisDimension | undefined = accessors.find( + (a) => getAccessorByDimension(a, table.columns) === id + ); + + return { + ...formatters, + [id]: formatFactory(accessor ? getFormat(table.columns, accessor) : meta.params), + }; + }, {} ); @@ -128,7 +145,7 @@ export const getFormattedTable = ( row, table.columns, columnsFormatters, - xAccessor, + xAccessor ? getAccessorByDimension(xAccessor, table.columns) : undefined, xScaleType ); return { @@ -153,28 +170,43 @@ export const getFormattedTablesByLayers = ( formatFactory: FormatFactory ): DatatablesWithFormatInfo => layers.reduce( - (formattedDatatables, { layerId, table, xAccessor, xScaleType }) => ({ + (formattedDatatables, { layerId, table, xAccessor, splitAccessor, accessors, xScaleType }) => ({ ...formattedDatatables, - [layerId]: getFormattedTable(table, formatFactory, xAccessor, xScaleType), + [layerId]: getFormattedTable( + table, + formatFactory, + xAccessor, + [xAccessor, splitAccessor, ...accessors].filter( + (a): a is string | ExpressionValueVisDimension => a !== undefined + ), + xScaleType + ), }), {} ); const getSeriesName: GetSeriesNameFn = ( data, - { layer, splitHint, splitFormatter, alreadyFormattedColumns, columnToLabelMap } + { + splitColumnId, + accessorsCount, + splitHint, + splitFormatter, + alreadyFormattedColumns, + columnToLabelMap, + } ) => { // For multiple y series, the name of the operation is used on each, either: // * Key - Y name // * Formatted value - Y name - if (layer.splitAccessor && layer.accessors.length > 1) { - const formatted = alreadyFormattedColumns[layer.splitAccessor]; + if (splitColumnId && accessorsCount > 1) { + const formatted = alreadyFormattedColumns[splitColumnId]; const result = data.seriesKeys .map((key: string | number, i) => { - if (i === 0 && splitHint && layer.splitAccessor && !formatted) { + if (i === 0 && splitHint && splitColumnId && !formatted) { return splitFormatter.convert(key); } - return layer.splitAccessor && i === 0 ? key : columnToLabelMap[key] ?? null; + return splitColumnId && i === 0 ? key : columnToLabelMap[key] ?? null; }) .join(' - '); return result; @@ -183,7 +215,7 @@ const getSeriesName: GetSeriesNameFn = ( // For formatted split series, format the key // This handles splitting by dates, for example if (splitHint) { - if (layer.splitAccessor && alreadyFormattedColumns[layer.splitAccessor]) { + if (splitColumnId && alreadyFormattedColumns[splitColumnId]) { return data.seriesKeys[0]; } return splitFormatter.convert(data.seriesKeys[0]); @@ -191,7 +223,7 @@ const getSeriesName: GetSeriesNameFn = ( // This handles both split and single-y cases: // * If split series without formatting, show the value literally // * If single Y, the seriesKey will be the accessor, so we show the human-readable name - return layer.splitAccessor ? data.seriesKeys[0] : columnToLabelMap[data.seriesKeys[0]] ?? null; + return splitColumnId ? data.seriesKeys[0] : columnToLabelMap[data.seriesKeys[0]] ?? null; }; const getPointConfig = (xAccessor?: string, emphasizeFitting?: boolean) => ({ @@ -248,13 +280,18 @@ export const getSeriesProps: GetSeriesPropsFn = ({ const isStacked = layer.seriesType.includes('stacked'); const isPercentage = layer.seriesType.includes('percentage'); const isBarChart = layer.seriesType.includes('bar'); + const xColumnId = layer.xAccessor && getAccessorByDimension(layer.xAccessor, table.columns); + const splitColumnId = + layer.splitAccessor && getAccessorByDimension(layer.splitAccessor, table.columns); const enableHistogramMode = layer.isHistogram && (isStacked || !layer.splitAccessor) && (isStacked || !isBarChart || !chartHasMoreThanOneBarSeries); const formatter = table?.columns.find((column) => column.id === accessor)?.meta?.params; - const splitHint = table?.columns.find((col) => col.id === layer.splitAccessor)?.meta?.params; + const splitHint = layer.splitAccessor + ? getFormatByAccessor(layer.splitAccessor, table.columns) + : undefined; const splitFormatter = formatFactory(splitHint); // what if row values are not primitive? That is the case of, for instance, Ranges @@ -266,15 +303,15 @@ export const getSeriesProps: GetSeriesPropsFn = ({ // To not display them in the legend, they need to be filtered out. let rows = formattedTable.rows.filter( (row) => - !(layer.xAccessor && typeof row[layer.xAccessor] === 'undefined') && + !(xColumnId && typeof row[xColumnId] === 'undefined') && !( - layer.splitAccessor && - typeof row[layer.splitAccessor] === 'undefined' && + splitColumnId && + typeof row[splitColumnId] === 'undefined' && typeof row[accessor] === 'undefined' ) ); - if (!layer.xAccessor) { + if (!xColumnId) { rows = rows.map((row) => ({ ...row, unifiedX: i18n.translate('expressionXY.xyChart.emptyXLabel', { @@ -284,17 +321,17 @@ export const getSeriesProps: GetSeriesPropsFn = ({ } return { - splitSeriesAccessors: layer.splitAccessor ? [layer.splitAccessor] : [], - stackAccessors: isStacked ? [layer.xAccessor as string] : [], - id: layer.splitAccessor ? `${layer.splitAccessor}-${accessor}` : `${accessor}`, - xAccessor: layer.xAccessor || 'unifiedX', + splitSeriesAccessors: splitColumnId ? [splitColumnId] : [], + stackAccessors: isStacked ? [xColumnId as string] : [], + id: splitColumnId ? `${splitColumnId}-${accessor}` : accessor, + xAccessor: xColumnId || 'unifiedX', yAccessors: [accessor], data: rows, - xScaleType: layer.xAccessor ? layer.xScaleType : 'ordinal', + xScaleType: xColumnId ? layer.xScaleType : 'ordinal', yScaleType: - formatter?.id === 'bytes' && layer.yScaleType === ScaleType.Linear + formatter?.id === 'bytes' && yAxis?.scale === ScaleType.Linear ? ScaleType.LinearBinary - : layer.yScaleType, + : yAxis?.scale || ScaleType.Linear, color: (series) => getColor(series, { layer, @@ -309,19 +346,20 @@ export const getSeriesProps: GetSeriesPropsFn = ({ stackMode: isPercentage ? StackMode.Percentage : undefined, timeZone, areaSeriesStyle: { - point: getPointConfig(layer.xAccessor, emphasizeFitting), + point: getPointConfig(xColumnId, emphasizeFitting), ...(fillOpacity && { area: { opacity: fillOpacity } }), ...(emphasizeFitting && { fit: { area: { opacity: fillOpacity || 0.5 }, line: getLineConfig() }, }), }, lineSeriesStyle: { - point: getPointConfig(layer.xAccessor, emphasizeFitting), + point: getPointConfig(xColumnId, emphasizeFitting), ...(emphasizeFitting && { fit: { line: getLineConfig() } }), }, name(d) { return getSeriesName(d, { - layer, + splitColumnId, + accessorsCount: layer.accessors.length, splitHint, splitFormatter, alreadyFormattedColumns: formattedColumns, diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/format.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/format.ts new file mode 100644 index 000000000000000..3830f9cadead697 --- /dev/null +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/format.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { DatatableColumn } from '@kbn/expressions-plugin'; +import { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common/expression_functions'; +import { getFormatByAccessor, getColumnByAccessor } from '@kbn/visualizations-plugin/common/utils'; + +export const getFormat = ( + columns: DatatableColumn[], + accessor: string | ExpressionValueVisDimension +) => { + const type = getColumnByAccessor(accessor, columns)?.meta.type; + return getFormatByAccessor( + accessor, + columns, + type + ? { + id: type, + } + : undefined + ); +}; diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/index.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/index.ts index 773ae4ee22d940c..2fb2af16c08ae6b 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/index.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/index.ts @@ -16,3 +16,4 @@ export * from './icon'; export * from './color_assignment'; export * from './annotations'; export * from './data_layers'; +export * from './format'; diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/interval.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/interval.ts index 17e7a9c2aba3200..015cab5431e9eb6 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/interval.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/interval.ts @@ -7,6 +7,7 @@ */ import { search } from '@kbn/data-plugin/public'; +import { getColumnByAccessor } from '@kbn/visualizations-plugin/common/utils'; import { XYChartProps } from '../../common'; import { getFilteredLayers } from './layers'; import { isDataLayer } from './visualization'; @@ -15,9 +16,10 @@ export function calculateMinInterval({ args: { layers } }: XYChartProps) { const filteredLayers = getFilteredLayers(layers); if (filteredLayers.length === 0) return; const isTimeViz = filteredLayers.every((l) => isDataLayer(l) && l.xScaleType === 'time'); - const xColumn = filteredLayers[0].table.columns.find( - (column) => isDataLayer(filteredLayers[0]) && column.id === filteredLayers[0].xAccessor - ); + const xColumn = + isDataLayer(filteredLayers[0]) && + filteredLayers[0].xAccessor && + getColumnByAccessor(filteredLayers[0].xAccessor, filteredLayers[0].table.columns); if (!xColumn) return; if (!isTimeViz) { diff --git a/src/plugins/chart_expressions/expression_xy/public/helpers/layers.ts b/src/plugins/chart_expressions/expression_xy/public/helpers/layers.ts index 4408ebd3feb84e6..9934cc4f78fa9d0 100644 --- a/src/plugins/chart_expressions/expression_xy/public/helpers/layers.ts +++ b/src/plugins/chart_expressions/expression_xy/public/helpers/layers.ts @@ -7,6 +7,8 @@ */ import { Datatable } from '@kbn/expressions-plugin/common'; +import { getAccessorByDimension } from '@kbn/visualizations-plugin/common/utils'; +import type { ExpressionValueVisDimension } from '@kbn/visualizations-plugin/common/expression_functions'; import { CommonXYDataLayerConfig, CommonXYLayerConfig, @@ -18,20 +20,24 @@ export function getFilteredLayers(layers: CommonXYLayerConfig[]) { return layers.filter( (layer): layer is CommonXYReferenceLineLayerConfig | CommonXYDataLayerConfig => { let table: Datatable | undefined; - let accessors: string[] = []; + let accessors: Array = []; let xAccessor: undefined | string | number; let splitAccessor: undefined | string | number; - if (isDataLayer(layer)) { - xAccessor = layer.xAccessor; - splitAccessor = layer.splitAccessor; - } - if (isDataLayer(layer) || isReferenceLayer(layer)) { table = layer.table; accessors = layer.accessors; } + if (isDataLayer(layer)) { + xAccessor = + layer.xAccessor && table && getAccessorByDimension(layer.xAccessor, table.columns); + splitAccessor = + layer.splitAccessor && + table && + getAccessorByDimension(layer.splitAccessor, table.columns); + } + return !( !accessors.length || !table || diff --git a/src/plugins/chart_expressions/expression_xy/public/plugin.ts b/src/plugins/chart_expressions/expression_xy/public/plugin.ts index 5e68d2c621894d5..5c27da6b82b287b 100755 --- a/src/plugins/chart_expressions/expression_xy/public/plugin.ts +++ b/src/plugins/chart_expressions/expression_xy/public/plugin.ts @@ -17,7 +17,6 @@ import { ExpressionXyPluginSetup, ExpressionXyPluginStart, SetupDeps } from './t import { xyVisFunction, layeredXyVisFunction, - dataLayerFunction, extendedDataLayerFunction, yAxisConfigFunction, extendedYAxisConfigFunction, @@ -59,7 +58,6 @@ export class ExpressionXyPlugin { expressions.registerFunction(extendedYAxisConfigFunction); expressions.registerFunction(legendConfigFunction); expressions.registerFunction(gridlinesConfigFunction); - expressions.registerFunction(dataLayerFunction); expressions.registerFunction(extendedDataLayerFunction); expressions.registerFunction(axisExtentConfigFunction); expressions.registerFunction(tickLabelsConfigFunction); diff --git a/src/plugins/chart_expressions/expression_xy/server/plugin.ts b/src/plugins/chart_expressions/expression_xy/server/plugin.ts index 37252a72965803d..cefde5d38a5f481 100755 --- a/src/plugins/chart_expressions/expression_xy/server/plugin.ts +++ b/src/plugins/chart_expressions/expression_xy/server/plugin.ts @@ -15,7 +15,6 @@ import { extendedYAxisConfigFunction, legendConfigFunction, gridlinesConfigFunction, - dataLayerFunction, axisExtentConfigFunction, tickLabelsConfigFunction, annotationLayerFunction, @@ -37,7 +36,6 @@ export class ExpressionXyPlugin expressions.registerFunction(extendedYAxisConfigFunction); expressions.registerFunction(legendConfigFunction); expressions.registerFunction(gridlinesConfigFunction); - expressions.registerFunction(dataLayerFunction); expressions.registerFunction(extendedDataLayerFunction); expressions.registerFunction(axisExtentConfigFunction); expressions.registerFunction(tickLabelsConfigFunction); diff --git a/src/plugins/charts/public/static/components/legend_toggle.tsx b/src/plugins/charts/public/static/components/legend_toggle.tsx index 0630fef080323c0..a4d53e5d54ae6b7 100644 --- a/src/plugins/charts/public/static/components/legend_toggle.tsx +++ b/src/plugins/charts/public/static/components/legend_toggle.tsx @@ -8,7 +8,7 @@ import React, { memo, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; -import { htmlIdGenerator, EuiButtonIcon, useEuiTheme } from '@elastic/eui'; +import { EuiButtonIcon, useEuiTheme } from '@elastic/eui'; import { Position } from '@elastic/charts'; import { css } from '@emotion/react'; @@ -19,7 +19,6 @@ export interface LegendToggleProps { } const LegendToggleComponent = ({ onClick, showLegend, legendPosition }: LegendToggleProps) => { - const legendId = useMemo(() => htmlIdGenerator()('legend'), []); const { euiTheme } = useEuiTheme(); const baseStyles = useMemo( @@ -65,7 +64,6 @@ const LegendToggleComponent = ({ onClick, showLegend, legendPosition }: LegendTo defaultMessage: 'Toggle legend', })} aria-expanded={showLegend} - aria-controls={legendId} isSelected={showLegend} data-test-subj="vislibToggleLegend" title={i18n.translate('charts.legend.toggleLegendButtonTitle', { diff --git a/src/plugins/charts/public/static/utils/transform_click_event.ts b/src/plugins/charts/public/static/utils/transform_click_event.ts index 3d0cc2b47b21592..ec35fa85d59a17b 100644 --- a/src/plugins/charts/public/static/utils/transform_click_event.ts +++ b/src/plugins/charts/public/static/utils/transform_click_event.ts @@ -19,7 +19,7 @@ import { RangeSelectContext, ValueClickContext } from '@kbn/embeddable-plugin/pu import { Datatable } from '@kbn/expressions-plugin/public'; export interface ClickTriggerEvent { - name: 'filterBucket'; + name: 'filter'; data: ValueClickContext['data']; } @@ -214,7 +214,7 @@ export const getFilterFromChartClickEventFn = }); return { - name: 'filterBucket', + name: 'filter', data: { negate, data, @@ -250,7 +250,7 @@ export const getFilterFromSeriesFn = })); return { - name: 'filterBucket', + name: 'filter', data: { negate, data, diff --git a/src/plugins/controls/common/control_types/options_list/types.ts b/src/plugins/controls/common/control_types/options_list/types.ts index 0f889bed7bacbde..7dfdfab742d1ad0 100644 --- a/src/plugins/controls/common/control_types/options_list/types.ts +++ b/src/plugins/controls/common/control_types/options_list/types.ts @@ -6,27 +6,60 @@ * Side Public License, v 1. */ -import { BoolQuery } from '@kbn/es-query'; -import { FieldSpec } from '@kbn/data-views-plugin/common'; +import { TimeRange } from '@kbn/data-plugin/common'; +import { Filter, Query, BoolQuery } from '@kbn/es-query'; +import { FieldSpec, DataView, DataViewField } from '@kbn/data-views-plugin/common'; + import { DataControlInput } from '../../types'; export const OPTIONS_LIST_CONTROL = 'optionsListControl'; export interface OptionsListEmbeddableInput extends DataControlInput { selectedOptions?: string[]; + runPastTimeout?: boolean; + textFieldName?: string; singleSelect?: boolean; loading?: boolean; } +export type OptionsListField = DataViewField & { + textFieldName?: string; + parentFieldName?: string; + childFieldName?: string; +}; + +/** + * The Options list response is returned from the serverside Options List route. + */ export interface OptionsListResponse { suggestions: string[]; totalCardinality: number; invalidSelections?: string[]; } +/** + * The Options list request type taken in by the public Options List service. + */ +export type OptionsListRequest = Omit< + OptionsListRequestBody, + 'filters' | 'fieldName' | 'fieldSpec' | 'textFieldName' +> & { + timeRange?: TimeRange; + field: OptionsListField; + runPastTimeout?: boolean; + dataView: DataView; + filters?: Filter[]; + query?: Query; +}; + +/** + * The Options list request body is sent to the serverside Options List route and is used to create the ES query. + */ export interface OptionsListRequestBody { filters?: Array<{ bool: BoolQuery }>; selectedOptions?: string[]; + runPastTimeout?: boolean; + textFieldName?: string; searchString?: string; fieldSpec?: FieldSpec; fieldName: string; diff --git a/src/plugins/controls/public/__stories__/controls.stories.tsx b/src/plugins/controls/public/__stories__/controls.stories.tsx index 481016af72a36fd..e8133e7dae503e7 100644 --- a/src/plugins/controls/public/__stories__/controls.stories.tsx +++ b/src/plugins/controls/public/__stories__/controls.stories.tsx @@ -31,12 +31,11 @@ import { decorators } from './decorators'; import { ControlsPanels } from '../control_group/types'; import { ControlGroupContainer } from '../control_group'; import { pluginServices, registry } from '../services/storybook'; -import { replaceValueSuggestionMethod } from '../services/storybook/unified_search'; import { injectStorybookDataView } from '../services/storybook/data_views'; -import { populateStorybookControlFactories } from './storybook_control_factories'; -import { OptionsListRequest } from '../services/options_list'; -import { OptionsListResponse } from '../control_types/options_list/types'; import { replaceOptionsListMethod } from '../services/storybook/options_list'; +import { populateStorybookControlFactories } from './storybook_control_factories'; +import { replaceValueSuggestionMethod } from '../services/storybook/unified_search'; +import { OptionsListResponse, OptionsListRequest } from '../control_types/options_list/types'; export default { title: 'Controls', diff --git a/src/plugins/controls/public/control_group/component/control_frame_component.tsx b/src/plugins/controls/public/control_group/component/control_frame_component.tsx index 36879bea110d8d5..dabe351376b7fcb 100644 --- a/src/plugins/controls/public/control_group/component/control_frame_component.tsx +++ b/src/plugins/controls/public/control_group/component/control_frame_component.tsx @@ -28,9 +28,15 @@ export interface ControlFrameProps { customPrepend?: JSX.Element; enableActions?: boolean; embeddableId: string; + embeddableType: string; } -export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: ControlFrameProps) => { +export const ControlFrame = ({ + customPrepend, + enableActions, + embeddableId, + embeddableType, +}: ControlFrameProps) => { const embeddableRoot: React.RefObject = useMemo(() => React.createRef(), []); const { useEmbeddableSelector, @@ -42,7 +48,7 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con const { overlays } = pluginServices.getHooks(); const { openConfirm } = overlays.useService(); - const embeddable = useChildEmbeddable({ untilEmbeddableLoaded, embeddableId }); + const embeddable = useChildEmbeddable({ untilEmbeddableLoaded, embeddableId, embeddableType }); const [title, setTitle] = useState(); diff --git a/src/plugins/controls/public/control_group/component/control_group_component.tsx b/src/plugins/controls/public/control_group/component/control_group_component.tsx index 3abee52002db1be..72dc49b2f9fbbee 100644 --- a/src/plugins/controls/public/control_group/component/control_group_component.tsx +++ b/src/plugins/controls/public/control_group/component/control_group_component.tsx @@ -144,6 +144,7 @@ export const ControlGroup = () => { isEditable={isEditable} dragInfo={{ index, draggingIndex }} embeddableId={controlId} + embeddableType={panels[controlId].type} key={controlId} /> ) diff --git a/src/plugins/controls/public/control_group/component/control_group_sortable_item.tsx b/src/plugins/controls/public/control_group/component/control_group_sortable_item.tsx index 2741752b4df885c..bdf1851a0daa158 100644 --- a/src/plugins/controls/public/control_group/component/control_group_sortable_item.tsx +++ b/src/plugins/controls/public/control_group/component/control_group_sortable_item.tsx @@ -60,44 +60,50 @@ export const SortableControl = (frameProps: SortableControlProps) => { const SortableControlInner = forwardRef< HTMLButtonElement, SortableControlProps & { style: HTMLAttributes['style'] } ->(({ embeddableId, dragInfo, style, isEditable, ...dragHandleProps }, dragHandleRef) => { - const { isOver, isDragging, draggingIndex, index } = dragInfo; - const { useEmbeddableSelector } = useReduxContainerContext(); - const { panels } = useEmbeddableSelector((state) => state); +>( + ( + { embeddableId, embeddableType, dragInfo, style, isEditable, ...dragHandleProps }, + dragHandleRef + ) => { + const { isOver, isDragging, draggingIndex, index } = dragInfo; + const { useEmbeddableSelector } = useReduxContainerContext(); + const { panels } = useEmbeddableSelector((state) => state); - const width = panels[embeddableId].width; + const width = panels[embeddableId].width; - const dragHandle = ( - - ); + const dragHandle = ( + + ); - return ( - (draggingIndex ?? -1), - })} - style={style} - > - - - ); -}); + return ( + (draggingIndex ?? -1), + })} + style={style} + > + + + ); + } +); /** * A simplified clone version of the control which is dragged. This version only shows diff --git a/src/plugins/controls/public/control_group/editor/control_editor.tsx b/src/plugins/controls/public/control_group/editor/control_editor.tsx index 3cd5b92e503c13b..eb7eff4abb42a80 100644 --- a/src/plugins/controls/public/control_group/editor/control_editor.tsx +++ b/src/plugins/controls/public/control_group/editor/control_editor.tsx @@ -41,6 +41,7 @@ import { ControlEmbeddable, ControlInput, ControlWidth, + DataControlInput, IEditableControlFactory, } from '../../types'; import { CONTROL_WIDTH_OPTIONS } from './editor_constants'; @@ -85,6 +86,11 @@ export const ControlEditor = ({ const [currentTitle, setCurrentTitle] = useState(title); const [currentWidth, setCurrentWidth] = useState(width); const [controlEditorValid, setControlEditorValid] = useState(false); + const [selectedField, setSelectedField] = useState( + embeddable + ? (embeddable.getInput() as DataControlInput).fieldName // CLEAN THIS ONCE OTHER PR GETS IN + : undefined + ); const getControlTypeEditor = (type: string) => { const factory = getControlFactory(type); @@ -96,6 +102,8 @@ export const ControlEditor = ({ onChange={onTypeEditorChange} setValidState={setControlEditorValid} initialInput={embeddable?.getInput()} + selectedField={selectedField} + setSelectedField={setSelectedField} setDefaultTitle={(newDefaultTitle) => { if (!currentTitle || currentTitle === defaultTitle) { setCurrentTitle(newDefaultTitle); @@ -107,8 +115,8 @@ export const ControlEditor = ({ ) : null; }; - const getTypeButtons = (controlTypes: string[]) => { - return controlTypes.map((type) => { + const getTypeButtons = () => { + return getControlTypes().map((type) => { const factory = getControlFactory(type); const icon = (factory as EmbeddableFactoryDefinition).getIconType?.(); const tooltip = (factory as EmbeddableFactoryDefinition).getDescription?.(); @@ -120,6 +128,12 @@ export const ControlEditor = ({ isSelected={selectedType === type} onClick={() => { setSelectedType(type); + if (!isCreate) + setSelectedField( + embeddable && type === embeddable.type + ? (embeddable.getInput() as DataControlInput).fieldName + : undefined + ); }} > @@ -150,9 +164,7 @@ export const ControlEditor = ({ - - {isCreate ? getTypeButtons(getControlTypes()) : getTypeButtons([selectedType])} - + {getTypeButtons()} {selectedType && ( <> diff --git a/src/plugins/controls/public/control_group/editor/control_group_editor.tsx b/src/plugins/controls/public/control_group/editor/control_group_editor.tsx index 8917769f6b1517d..003468a21e39470 100644 --- a/src/plugins/controls/public/control_group/editor/control_group_editor.tsx +++ b/src/plugins/controls/public/control_group/editor/control_group_editor.tsx @@ -14,9 +14,8 @@ * Side Public License, v 1. */ -import { omit } from 'lodash'; import fastIsEqual from 'fast-deep-equal'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { EuiFlyoutHeader, EuiButtonGroup, @@ -31,8 +30,6 @@ import { EuiSpacer, EuiCheckbox, EuiForm, - EuiAccordion, - useGeneratedHtmlId, EuiSwitch, EuiText, EuiHorizontalRule, @@ -68,7 +65,6 @@ export const ControlGroupEditor = ({ onClose, }: EditControlGroupProps) => { const [resetAllWidths, setResetAllWidths] = useState(false); - const advancedSettingsAccordionId = useGeneratedHtmlId({ prefix: 'advancedSettingsAccordion' }); const [controlGroupEditorState, setControlGroupEditorState] = useState({ defaultControlWidth: DEFAULT_CONTROL_WIDTH, @@ -99,14 +95,6 @@ export const ControlGroupEditor = ({ [controlGroupEditorState] ); - const fullQuerySyncActive = useMemo( - () => - !Object.values(omit(controlGroupEditorState.ignoreParentSettings, 'ignoreValidations')).some( - Boolean - ), - [controlGroupEditorState] - ); - const applyChangesToInput = useCallback(() => { const inputToApply = { ...controlGroupEditorState }; if (resetAllWidths) { @@ -177,70 +165,6 @@ export const ControlGroupEditor = ({ - - - - { - const newSetting = !e.target.checked; - updateIgnoreSetting({ - ignoreFilters: newSetting, - ignoreTimerange: newSetting, - ignoreQuery: newSetting, - }); - }} - /> - - - -

    {ControlGroupStrings.management.querySync.getQuerySettingsTitle()}

    -
    - -

    {ControlGroupStrings.management.querySync.getQuerySettingsSubtitle()}

    -
    - - - - - updateIgnoreSetting({ ignoreTimerange: e.target.checked })} - /> - - - updateIgnoreSetting({ ignoreQuery: e.target.checked })} - /> - - - updateIgnoreSetting({ ignoreFilters: e.target.checked })} - /> - - -
    -
    - diff --git a/src/plugins/controls/public/control_group/editor/edit_control.tsx b/src/plugins/controls/public/control_group/editor/edit_control.tsx index 11a2e705a13f377..6866148ac7e9dad 100644 --- a/src/plugins/controls/public/control_group/editor/edit_control.tsx +++ b/src/plugins/controls/public/control_group/editor/edit_control.tsx @@ -22,6 +22,11 @@ import { IEditableControlFactory, ControlInput } from '../../types'; import { controlGroupReducers } from '../state/control_group_reducers'; import { ControlGroupContainer, setFlyoutRef } from '../embeddable/control_group_container'; +interface EditControlResult { + type: string; + controlInput: Omit; +} + export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => { // Controls Services Context const { overlays, controls } = pluginServices.getHooks(); @@ -34,7 +39,7 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => typeof controlGroupReducers >(); const { - containerActions: { untilEmbeddableLoaded, removeEmbeddable, updateInputForChild }, + containerActions: { untilEmbeddableLoaded, removeEmbeddable, replaceEmbeddable }, actions: { setControlWidth }, useEmbeddableSelector, useEmbeddableDispatch, @@ -52,88 +57,107 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => const editControl = async () => { const panel = panels[embeddableId]; - const factory = getControlFactory(panel.type); + let factory = getControlFactory(panel.type); + if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); + const embeddable = await untilEmbeddableLoaded(embeddableId); const controlGroup = embeddable.getRoot() as ControlGroupContainer; - let inputToReturn: Partial = {}; + const initialInputPromise = new Promise((resolve, reject) => { + let inputToReturn: Partial = {}; - if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); + let removed = false; + const onCancel = (ref: OverlayRef) => { + if ( + removed || + (isEqual(latestPanelState.current.explicitInput, { + ...panel.explicitInput, + ...inputToReturn, + }) && + isEqual(latestPanelState.current.width, panel.width)) + ) { + reject(); + ref.close(); + return; + } + openConfirm(ControlGroupStrings.management.discardChanges.getSubtitle(), { + confirmButtonText: ControlGroupStrings.management.discardChanges.getConfirm(), + cancelButtonText: ControlGroupStrings.management.discardChanges.getCancel(), + title: ControlGroupStrings.management.discardChanges.getTitle(), + buttonColor: 'danger', + }).then((confirmed) => { + if (confirmed) { + dispatch(setControlWidth({ width: panel.width, embeddableId })); + reject(); + ref.close(); + } + }); + }; - let removed = false; - const onCancel = (ref: OverlayRef) => { - if ( - removed || - (isEqual(latestPanelState.current.explicitInput, { - ...panel.explicitInput, - ...inputToReturn, - }) && - isEqual(latestPanelState.current.width, panel.width)) - ) { + const onSave = (type: string, ref: OverlayRef) => { + // if the control now has a new type, need to replace the old factory with + // one of the correct new type + if (latestPanelState.current.type !== type) { + factory = getControlFactory(type); + if (!factory) throw new EmbeddableFactoryNotFoundError(type); + } + const editableFactory = factory as IEditableControlFactory; + if (editableFactory.presaveTransformFunction) { + inputToReturn = editableFactory.presaveTransformFunction(inputToReturn, embeddable); + } + resolve({ type, controlInput: inputToReturn }); ref.close(); - return; - } - openConfirm(ControlGroupStrings.management.discardChanges.getSubtitle(), { - confirmButtonText: ControlGroupStrings.management.discardChanges.getConfirm(), - cancelButtonText: ControlGroupStrings.management.discardChanges.getCancel(), - title: ControlGroupStrings.management.discardChanges.getTitle(), - buttonColor: 'danger', - }).then((confirmed) => { - if (confirmed) { - dispatch(setControlWidth({ width: panel.width, embeddableId })); - ref.close(); + }; + + const flyoutInstance = openFlyout( + forwardAllContext( + onCancel(flyoutInstance)} + updateTitle={(newTitle) => (inputToReturn.title = newTitle)} + setLastUsedDataViewId={(lastUsed) => controlGroup.setLastUsedDataViewId(lastUsed)} + updateWidth={(newWidth) => dispatch(setControlWidth({ width: newWidth, embeddableId }))} + onTypeEditorChange={(partialInput) => { + inputToReturn = { ...inputToReturn, ...partialInput }; + }} + onSave={(type) => onSave(type, flyoutInstance)} + removeControl={() => { + openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { + confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), + cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), + title: ControlGroupStrings.management.deleteControls.getDeleteTitle(), + buttonColor: 'danger', + }).then((confirmed) => { + if (confirmed) { + removeEmbeddable(embeddableId); + removed = true; + flyoutInstance.close(); + } + }); + }} + />, + reduxContainerContext + ), + { + outsideClickCloses: false, + onClose: (flyout) => { + setFlyoutRef(undefined); + onCancel(flyout); + }, } - }); - }; + ); + setFlyoutRef(flyoutInstance); + }); - const flyoutInstance = openFlyout( - forwardAllContext( - onCancel(flyoutInstance)} - updateTitle={(newTitle) => (inputToReturn.title = newTitle)} - setLastUsedDataViewId={(lastUsed) => controlGroup.setLastUsedDataViewId(lastUsed)} - updateWidth={(newWidth) => dispatch(setControlWidth({ width: newWidth, embeddableId }))} - onTypeEditorChange={(partialInput) => - (inputToReturn = { ...inputToReturn, ...partialInput }) - } - onSave={() => { - const editableFactory = factory as IEditableControlFactory; - if (editableFactory.presaveTransformFunction) { - inputToReturn = editableFactory.presaveTransformFunction(inputToReturn, embeddable); - } - updateInputForChild(embeddableId, inputToReturn); - flyoutInstance.close(); - }} - removeControl={() => { - openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { - confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), - cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), - title: ControlGroupStrings.management.deleteControls.getDeleteTitle(), - buttonColor: 'danger', - }).then((confirmed) => { - if (confirmed) { - removeEmbeddable(embeddableId); - removed = true; - flyoutInstance.close(); - } - }); - }} - />, - reduxContainerContext - ), - { - outsideClickCloses: false, - onClose: (flyout) => { - setFlyoutRef(undefined); - onCancel(flyout); - }, - } + initialInputPromise.then( + async (promise) => { + await replaceEmbeddable(embeddable.id, promise.controlInput, promise.type); + }, + () => {} // swallow promise rejection because it can be part of normal flow ); - setFlyoutRef(flyoutInstance); }; return ( diff --git a/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx b/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx index d77cf2b2c1a7122..b6d5a0877d7ce8b 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx @@ -8,26 +8,26 @@ import useMount from 'react-use/lib/useMount'; import React, { useEffect, useState } from 'react'; -import { EuiFormRow, EuiSwitch } from '@elastic/eui'; -import { DataViewListItem, DataView } from '@kbn/data-views-plugin/common'; import { LazyDataViewPicker, LazyFieldPicker, withSuspense, } from '@kbn/presentation-util-plugin/public'; +import { IFieldSubTypeMulti } from '@kbn/es-query'; +import { EuiFormRow, EuiSwitch } from '@elastic/eui'; +import { DataViewListItem, DataView } from '@kbn/data-views-plugin/common'; + import { pluginServices } from '../../services'; import { ControlEditorProps } from '../../types'; -import { OptionsListEmbeddableInput } from './types'; import { OptionsListStrings } from './options_list_strings'; - +import { OptionsListEmbeddableInput, OptionsListField } from './types'; interface OptionsListEditorState { singleSelect?: boolean; - + runPastTimeout?: boolean; dataViewListItems: DataViewListItem[]; - + fieldsMap?: { [key: string]: OptionsListField }; dataView?: DataView; - fieldName?: string; } const FieldPicker = withSuspense(LazyFieldPicker, null); @@ -40,20 +40,22 @@ export const OptionsListEditor = ({ setDefaultTitle, getRelevantDataViewId, setLastUsedDataViewId, + selectedField, + setSelectedField, }: ControlEditorProps) => { // Controls Services Context const { dataViews } = pluginServices.getHooks(); const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); const [state, setState] = useState({ - fieldName: initialInput?.fieldName, singleSelect: initialInput?.singleSelect, + runPastTimeout: initialInput?.runPastTimeout, dataViewListItems: [], }); useMount(() => { let mounted = true; - if (state.fieldName) setDefaultTitle(state.fieldName); + if (selectedField) setDefaultTitle(selectedField); (async () => { const dataViewListItems = await getIdsWithTitle(); const initialId = @@ -64,19 +66,60 @@ export const OptionsListEditor = ({ dataView = await get(initialId); } if (!mounted) return; - setState((s) => ({ ...s, dataView, dataViewListItems })); + setState((s) => ({ ...s, dataView, dataViewListItems, fieldsMap: {} })); })(); return () => { mounted = false; }; }); + useEffect(() => { + if (!state.dataView) return; + + // double link the parent-child relationship so that we can filter in fields which are multi-typed to text / keyword + const doubleLinkedFields: OptionsListField[] = state.dataView?.fields.getAll(); + for (const field of doubleLinkedFields) { + const parentFieldName = (field.subType as IFieldSubTypeMulti)?.multi?.parent; + if (parentFieldName) { + (field as OptionsListField).parentFieldName = parentFieldName; + const parentField = state.dataView?.getFieldByName(parentFieldName); + (parentField as OptionsListField).childFieldName = field.name; + } + } + + const newFieldsMap: OptionsListEditorState['fieldsMap'] = {}; + for (const field of doubleLinkedFields) { + if (field.type === 'boolean') { + newFieldsMap[field.name] = field; + } + + // field type is keyword, check if this field is related to a text mapped field and include it. + else if (field.aggregatable && field.type === 'string') { + const childField = + (field.childFieldName && state.dataView?.fields.getByName(field.childFieldName)) || + undefined; + const parentField = + (field.parentFieldName && state.dataView?.fields.getByName(field.parentFieldName)) || + undefined; + + const textFieldName = childField?.esTypes?.includes('text') + ? childField.name + : parentField?.esTypes?.includes('text') + ? parentField.name + : undefined; + + newFieldsMap[field.name] = { ...field, textFieldName } as OptionsListField; + } + } + setState((s) => ({ ...s, fieldsMap: newFieldsMap })); + }, [state.dataView]); + useEffect( - () => setValidState(Boolean(state.fieldName) && Boolean(state.dataView)), - [state.fieldName, setValidState, state.dataView] + () => setValidState(Boolean(selectedField) && Boolean(state.dataView)), + [selectedField, setValidState, state.dataView] ); - const { dataView, fieldName } = state; + const { dataView } = state; return ( <> @@ -88,7 +131,7 @@ export const OptionsListEditor = ({ if (dataViewId === dataView?.id) return; onChange({ dataViewId }); - setState((s) => ({ ...s, fieldName: undefined })); + setSelectedField(undefined); get(dataViewId).then((newDataView) => { setState((s) => ({ ...s, dataView: newDataView })); }); @@ -100,15 +143,17 @@ export const OptionsListEditor = ({ - (field.aggregatable && field.type === 'string') || field.type === 'boolean' - } - selectedFieldName={fieldName} + filterPredicate={(field) => Boolean(state.fieldsMap?.[field.name])} + selectedFieldName={selectedField} dataView={dataView} onSelectField={(field) => { setDefaultTitle(field.displayName ?? field.name); - onChange({ fieldName: field.name }); - setState((s) => ({ ...s, fieldName: field.name })); + const textFieldName = state.fieldsMap?.[field.name].textFieldName; + onChange({ + fieldName: field.name, + textFieldName, + }); + setSelectedField(field.name); }} /> @@ -122,6 +167,16 @@ export const OptionsListEditor = ({ }} /> + + { + onChange({ runPastTimeout: !state.runPastTimeout }); + setState((s) => ({ ...s, runPastTimeout: !s.runPastTimeout })); + }} + /> + ); }; diff --git a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx index b315fd00392eab3..edf4cb6ddaff17f 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx @@ -20,21 +20,22 @@ import deepEqual from 'fast-deep-equal'; import { merge, Subject, Subscription, BehaviorSubject } from 'rxjs'; import { tap, debounceTime, map, distinctUntilChanged, skip } from 'rxjs/operators'; -import { DataView, DataViewField } from '@kbn/data-views-plugin/public'; -import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public'; import { withSuspense, LazyReduxEmbeddableWrapper, ReduxEmbeddableWrapperPropsWithChildren, } from '@kbn/presentation-util-plugin/public'; +import { DataView } from '@kbn/data-views-plugin/public'; +import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public'; + +import { OptionsListEmbeddableInput, OptionsListField, OPTIONS_LIST_CONTROL } from './types'; import { OptionsListComponent, OptionsListComponentState } from './options_list_component'; -import { OptionsListEmbeddableInput, OPTIONS_LIST_CONTROL } from './types'; +import { ControlsOptionsListService } from '../../services/options_list'; import { ControlsDataViewsService } from '../../services/data_views'; import { optionsListReducers } from './options_list_reducers'; import { OptionsListStrings } from './options_list_strings'; import { ControlInput, ControlOutput } from '../..'; import { pluginServices } from '../../services'; -import { ControlsOptionsListService } from '../../services/options_list'; const OptionsListReduxWrapper = withSuspense< ReduxEmbeddableWrapperPropsWithChildren @@ -76,7 +77,7 @@ export class OptionsListEmbeddable extends Embeddable = new Subject(); private abortController?: AbortController; private dataView?: DataView; - private field?: DataViewField; + private field?: OptionsListField; private searchString = ''; // State to be passed down to component @@ -176,9 +177,9 @@ export class OptionsListEmbeddable extends Embeddable => { - const { dataViewId, fieldName } = this.getInput(); + const { dataViewId, fieldName, textFieldName } = this.getInput(); if (!this.dataView || this.dataView.id !== dataViewId) { this.dataView = await this.dataViewsService.get(dataViewId); if (this.dataView === undefined) { @@ -190,7 +191,10 @@ export class OptionsListEmbeddable extends Embeddable + i18n.translate('controls.optionsList.editor.runPastTimeout', { + defaultMessage: 'Run past timeout', + }), }, popover: { getLoadingMessage: () => diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx index fa0c2c7d3cc45d7..13f688c5dd31823 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx @@ -24,7 +24,6 @@ import { RangeSliderStrings } from './range_slider_strings'; interface RangeSliderEditorState { dataViewListItems: DataViewListItem[]; dataView?: DataView; - fieldName?: string; } const FieldPicker = withSuspense(LazyFieldPicker, null); @@ -37,19 +36,20 @@ export const RangeSliderEditor = ({ setDefaultTitle, getRelevantDataViewId, setLastUsedDataViewId, + selectedField, + setSelectedField, }: ControlEditorProps) => { // Controls Services Context const { dataViews } = pluginServices.getHooks(); const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); const [state, setState] = useState({ - fieldName: initialInput?.fieldName, dataViewListItems: [], }); useMount(() => { let mounted = true; - if (state.fieldName) setDefaultTitle(state.fieldName); + if (selectedField) setDefaultTitle(selectedField); (async () => { const dataViewListItems = await getIdsWithTitle(); const initialId = @@ -68,11 +68,11 @@ export const RangeSliderEditor = ({ }); useEffect( - () => setValidState(Boolean(state.fieldName) && Boolean(state.dataView)), - [state.fieldName, setValidState, state.dataView] + () => setValidState(Boolean(selectedField) && Boolean(state.dataView)), + [selectedField, setValidState, state.dataView] ); - const { dataView, fieldName } = state; + const { dataView } = state; return ( <> @@ -84,7 +84,7 @@ export const RangeSliderEditor = ({ if (dataViewId === dataView?.id) return; onChange({ dataViewId }); - setState((s) => ({ ...s, fieldName: undefined })); + setSelectedField(undefined); get(dataViewId).then((newDataView) => { setState((s) => ({ ...s, dataView: newDataView })); }); @@ -97,12 +97,12 @@ export const RangeSliderEditor = ({ field.aggregatable && field.type === 'number'} - selectedFieldName={fieldName} + selectedFieldName={selectedField} dataView={dataView} onSelectField={(field) => { setDefaultTitle(field.displayName ?? field.name); onChange({ fieldName: field.name }); - setState((s) => ({ ...s, fieldName: field.name })); + setSelectedField(field.name); }} /> diff --git a/src/plugins/controls/public/control_types/time_slider/__stories__/time_slider.component.stories.tsx b/src/plugins/controls/public/control_types/time_slider/__stories__/time_slider.component.stories.tsx index 7ae7871497045af..90ea07dc276bd56 100644 --- a/src/plugins/controls/public/control_types/time_slider/__stories__/time_slider.component.stories.tsx +++ b/src/plugins/controls/public/control_types/time_slider/__stories__/time_slider.component.stories.tsx @@ -16,7 +16,7 @@ export default { description: '', }; -const TimeSliderWrapper: FC> = (props) => { +const TimeSliderWrapper: FC> = (props) => { const [value, setValue] = useState(props.value); const onChange = useCallback( (newValue: [number | null, number | null]) => { @@ -31,7 +31,13 @@ const TimeSliderWrapper: FC> = ( return (
    - +
    ); diff --git a/src/plugins/controls/public/control_types/time_slider/time_slider.component.tsx b/src/plugins/controls/public/control_types/time_slider/time_slider.component.tsx index 89efce270d14ca9..1bb2f90b44121f4 100644 --- a/src/plugins/controls/public/control_types/time_slider/time_slider.component.tsx +++ b/src/plugins/controls/public/control_types/time_slider/time_slider.component.tsx @@ -70,6 +70,7 @@ export function getInterval(min: number, max: number, steps = 6): number { } export interface TimeSliderProps { + id: string; range?: [number | undefined, number | undefined]; value: [number | null, number | null]; onChange: (range: [number | null, number | null]) => void; @@ -167,10 +168,15 @@ export const TimeSlider: FC = (props) => { } const button = ( - - - -
    -
    - - -
    -
    - - - -
    - - - - - } - onChange={[Function]} - pagination={ - Object { - "pageIndex": 0, - "pageSize": 20, - "pageSizeOptions": Array [ - 10, - 20, - 50, - ], - "showPerPageOptions": undefined, - "totalItemCount": 1, - } - } - responsive={true} - sorting={ - Object { - "allowNeutralSort": true, - "sort": undefined, - } - } - tableLayout="fixed" - > + class="euiSpacer euiSpacer--m css-hg1jdf-euiSpacer-m" + />
    -
    - -
    - -
    - -
    - - -
    - -
    - - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - > -
    -
    - - - -
    -
    -
    -
    -
    -
    -
    -
    - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - -
    - -
    -
    - column1 -
    -
    - -
    - -
    - 123 -
    -
    - -
    - -
    - -
    - -
    - -
    -
    -
    -
    - - -
    - -
    - - - -
    - -
    - - - - : - 20 - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - > -
    -
    - - - -
    -
    -
    -
    -
    - -
    - - - -
    -
    -
    -
    -
    -
    - - + class="euiText euiText--medium" + > +

    + The element did not provide any data. +

    - - - - - + +
    +
    +
    + `; -exports[`Inspector Data View component should support multiple datatables 1`] = ` - + loading + } intl={ Object { @@ -1774,1436 +147,655 @@ exports[`Inspector Data View component should support multiple datatables 1`] = "timeZone": null, } } - title="Test Data" > - +
    + loading +
    + +`; + +exports[`Inspector Data View component should render single table without selector 1`] = ` +Array [ +
    +
    +
    +
    +
    + +
    +
    +
    +
    , +
    , +
    - +
    -
    +
    -
    -

    - - There are 2 tables in total - -

    + + + + Sorting + + + +
    - - +
    +
    +
    + + + + + + + + + + + +
    +
    -
    - - + + + column1 + + + +
    - + column1 + +
    - -
    - - - Selected: - - -
    -
    - +
    - - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="inspectorTableChooser" - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - repositionOnScroll={true} - > -
    -
    - - - -
    -
    -
    -
    - + class="euiFlexGroup euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow" + /> +
    - - -
    - - +
    +
    +
    +
    +
    +
    - - + + + - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="inspectorDownloadData" - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - repositionOnScroll={true} + Rows per page: 20 + + + +
    +
    +
    +
    +
    - + 1 + + + + + + + +
    - - +
    , +] +`; + +exports[`Inspector Data View component should support multiple datatables 1`] = ` +Array [ +
    +
    +

    + There are 2 tables in total +

    +
    +
    - - +
    + + Selected: + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    - +
    + +
    +
    +
    +
    , +
    , +
    +
    +
    - - } - onChange={[Function]} - pagination={ - Object { - "pageIndex": 0, - "pageSize": 20, - "pageSizeOptions": Array [ - 10, - 20, - 50, - ], - "showPerPageOptions": undefined, - "totalItemCount": 1, - } - } - responsive={true} - sorting={ - Object { - "allowNeutralSort": true, - "sort": undefined, - } - } - tableLayout="fixed" +
    +
    -
    - +
    +
    - -
    - -
    - - + -
    - -
    - - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - > -
    -
    - - - -
    -
    -
    -
    -
    -
    -
    -
    - + Sorting + + +
    - - +
    +
    +
    +
    + + + + + + + + + + + +
    +
    + +
    - -
    - +
    +
    +
    + 123 +
    +
    - - - -
    - -
    - - - - : - 20 - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - > -
    -
    - - - -
    -
    -
    -
    -
    - -
    - - - -
    -
    -
    -
    -
    +
    - - +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    - - - - - +
    +
    + +
    +
    +
    +
    , +] `; diff --git a/src/plugins/data/public/utils/table_inspector_view/components/data_view.test.tsx b/src/plugins/data/public/utils/table_inspector_view/components/data_view.test.tsx index 00817e35167206b..08f5984ba9f6db4 100644 --- a/src/plugins/data/public/utils/table_inspector_view/components/data_view.test.tsx +++ b/src/plugins/data/public/utils/table_inspector_view/components/data_view.test.tsx @@ -63,7 +63,7 @@ describe('Inspector Data View', () => { adapters.tables.logDatatable({ columns: [{ id: '1' }], rows: [{ '1': 123 }] }); // After the loader has resolved we'll still need one update, to "flush" the state changes component.update(); - expect(component).toMatchSnapshot(); + expect(component.render()).toMatchSnapshot(); }); it('should render single table without selector', async () => { @@ -80,7 +80,7 @@ describe('Inspector Data View', () => { component.update(); expect(component.find('[data-test-subj="inspectorDataViewSelectorLabel"]')).toHaveLength(0); - expect(component).toMatchSnapshot(); + expect(component.render()).toMatchSnapshot(); }); it('should support multiple datatables', async () => { @@ -104,7 +104,7 @@ describe('Inspector Data View', () => { component.update(); expect(component.find('[data-test-subj="inspectorDataViewSelectorLabel"]')).toHaveLength(1); - expect(component).toMatchSnapshot(); + expect(component.render()).toMatchSnapshot(); }); }); }); diff --git a/src/plugins/data/server/search/strategies/common/async_utils.test.ts b/src/plugins/data/server/search/strategies/common/async_utils.test.ts new file mode 100644 index 000000000000000..7c90a0fd4c124e5 --- /dev/null +++ b/src/plugins/data/server/search/strategies/common/async_utils.test.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { getCommonDefaultAsyncSubmitParams, getCommonDefaultAsyncGetParams } from './async_utils'; +import moment from 'moment'; +import { SearchSessionsConfigSchema } from '../../../../config'; + +const getMockSearchSessionsConfig = ({ + enabled = true, + defaultExpiration = moment.duration(7, 'd'), +} = {}) => + ({ + enabled, + defaultExpiration, + } as SearchSessionsConfigSchema); + +describe('request utils', () => { + describe('getCommonDefaultAsyncSubmitParams', () => { + test('Uses `keep_alive` from default params if no `sessionId` is provided', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + }); + const params = getCommonDefaultAsyncSubmitParams(mockConfig, {}); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + + test('Uses `keep_alive` from config if enabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + }); + const params = getCommonDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_alive', '259200000ms'); + }); + + test('Uses `keepAlive` of `1m` if disabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: false, + }); + const params = getCommonDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + + test('Uses `keep_on_completion` if enabled', async () => { + const mockConfig = getMockSearchSessionsConfig({}); + const params = getCommonDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_on_completion', true); + }); + + test('Does not use `keep_on_completion` if disabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: false, + }); + const params = getCommonDefaultAsyncSubmitParams(mockConfig, { + sessionId: 'foo', + }); + expect(params).toHaveProperty('keep_on_completion', false); + }); + }); + + describe('getCommonDefaultAsyncGetParams', () => { + test('Uses `wait_for_completion_timeout`', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: true, + }); + const params = getCommonDefaultAsyncGetParams(mockConfig, {}); + expect(params).toHaveProperty('wait_for_completion_timeout'); + }); + + test('Uses `keep_alive` if `sessionId` is not provided', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: true, + }); + const params = getCommonDefaultAsyncGetParams(mockConfig, {}); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + + test('Has no `keep_alive` if `sessionId` is provided', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: true, + }); + const params = getCommonDefaultAsyncGetParams(mockConfig, { sessionId: 'foo' }); + expect(params).not.toHaveProperty('keep_alive'); + }); + + test('Uses `keep_alive` if `sessionId` is provided but sessions disabled', async () => { + const mockConfig = getMockSearchSessionsConfig({ + defaultExpiration: moment.duration(3, 'd'), + enabled: false, + }); + const params = getCommonDefaultAsyncGetParams(mockConfig, { sessionId: 'foo' }); + expect(params).toHaveProperty('keep_alive', '1m'); + }); + }); +}); diff --git a/src/plugins/data/server/search/strategies/common/async_utils.ts b/src/plugins/data/server/search/strategies/common/async_utils.ts new file mode 100644 index 000000000000000..46483ca3f3279cb --- /dev/null +++ b/src/plugins/data/server/search/strategies/common/async_utils.ts @@ -0,0 +1,62 @@ +/* + * 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 { + AsyncSearchSubmitRequest, + AsyncSearchGetRequest, +} from '@elastic/elasticsearch/lib/api/types'; +import { SearchSessionsConfigSchema } from '../../../../config'; +import { ISearchOptions } from '../../../../common'; + +/** + @internal + */ +export function getCommonDefaultAsyncSubmitParams( + searchSessionsConfig: SearchSessionsConfigSchema | null, + options: ISearchOptions +): Pick< + AsyncSearchSubmitRequest, + 'keep_alive' | 'wait_for_completion_timeout' | 'keep_on_completion' +> { + const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; + + const keepAlive = useSearchSessions + ? `${searchSessionsConfig!.defaultExpiration.asMilliseconds()}ms` + : '1m'; + + return { + // Wait up to 100ms for the response to return + wait_for_completion_timeout: '100ms', + // If search sessions are used, store and get an async ID even for short running requests. + keep_on_completion: useSearchSessions, + // The initial keepalive is as defined in defaultExpiration if search sessions are used or 1m otherwise. + keep_alive: keepAlive, + }; +} + +/** + @internal + */ +export function getCommonDefaultAsyncGetParams( + searchSessionsConfig: SearchSessionsConfigSchema | null, + options: ISearchOptions +): Pick { + const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; + + return { + // Wait up to 100ms for the response to return + wait_for_completion_timeout: '100ms', + ...(useSearchSessions + ? // Don't change the expiration of search requests that are tracked in a search session + undefined + : { + // We still need to do polling for searches not within the context of a search session or when search session disabled + keep_alive: '1m', + }), + }; +} diff --git a/src/plugins/data/server/search/strategies/eql_search/eql_search_strategy.ts b/src/plugins/data/server/search/strategies/eql_search/eql_search_strategy.ts index 33c6f387d65069a..13b4295fb7c6362 100644 --- a/src/plugins/data/server/search/strategies/eql_search/eql_search_strategy.ts +++ b/src/plugins/data/server/search/strategies/eql_search/eql_search_strategy.ts @@ -19,7 +19,8 @@ import { toEqlKibanaSearchResponse } from './response_utils'; import { EqlSearchResponse } from './types'; import { ISearchStrategy } from '../../types'; import { getDefaultSearchParams } from '../es_search'; -import { getDefaultAsyncGetParams, getIgnoreThrottled } from '../ese_search/request_utils'; +import { getIgnoreThrottled } from '../ese_search/request_utils'; +import { getCommonDefaultAsyncGetParams } from '../common/async_utils'; export const eqlSearchStrategyProvider = ( logger: Logger @@ -45,11 +46,11 @@ export const eqlSearchStrategyProvider = ( uiSettingsClient ); const params = id - ? getDefaultAsyncGetParams(null, options) + ? getCommonDefaultAsyncGetParams(null, options) : { ...(await getIgnoreThrottled(uiSettingsClient)), ...defaultParams, - ...getDefaultAsyncGetParams(null, options), + ...getCommonDefaultAsyncGetParams(null, options), ...request.params, }; const response = id diff --git a/src/plugins/data/server/search/strategies/ese_search/request_utils.ts b/src/plugins/data/server/search/strategies/ese_search/request_utils.ts index ea850c80f90b3f3..07f1c9d1ae9a54c 100644 --- a/src/plugins/data/server/search/strategies/ese_search/request_utils.ts +++ b/src/plugins/data/server/search/strategies/ese_search/request_utils.ts @@ -12,6 +12,10 @@ import { AsyncSearchSubmitRequest } from '@elastic/elasticsearch/lib/api/types'; import { ISearchOptions, UI_SETTINGS } from '../../../../common'; import { getDefaultSearchParams } from '../es_search'; import { SearchSessionsConfigSchema } from '../../../../config'; +import { + getCommonDefaultAsyncGetParams, + getCommonDefaultAsyncSubmitParams, +} from '../common/async_utils'; /** * @internal @@ -43,23 +47,10 @@ export async function getDefaultAsyncSubmitParams( | 'keep_on_completion' > > { - const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; - - // TODO: searchSessionsConfig could be "null" if we are running without x-pack which happens only in tests. - // This can be cleaned up when we completely stop separating basic and oss - const keepAlive = useSearchSessions - ? `${searchSessionsConfig!.defaultExpiration.asMilliseconds()}ms` - : '1m'; - return { // TODO: adjust for partial results batched_reduce_size: 64, - // Wait up to 100ms for the response to return - wait_for_completion_timeout: '100ms', - // If search sessions are used, store and get an async ID even for short running requests. - keep_on_completion: useSearchSessions, - // The initial keepalive is as defined in defaultExpiration if search sessions are used or 1m otherwise. - keep_alive: keepAlive, + ...getCommonDefaultAsyncSubmitParams(searchSessionsConfig, options), ...(await getIgnoreThrottled(uiSettingsClient)), ...(await getDefaultSearchParams(uiSettingsClient)), // If search sessions are used, set the initial expiration time. @@ -73,17 +64,7 @@ export function getDefaultAsyncGetParams( searchSessionsConfig: SearchSessionsConfigSchema | null, options: ISearchOptions ): Pick { - const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; - return { - // Wait up to 100ms for the response to return - wait_for_completion_timeout: '100ms', - ...(useSearchSessions - ? // Don't change the expiration of search requests that are tracked in a search session - undefined - : { - // We still need to do polling for searches not within the context of a search session or when search session disabled - keep_alive: '1m', - }), + ...getCommonDefaultAsyncGetParams(searchSessionsConfig, options), }; } diff --git a/src/plugins/data/server/search/strategies/sql_search/request_utils.ts b/src/plugins/data/server/search/strategies/sql_search/request_utils.ts index d05b2710b07ea1f..de8ced65d16c6c8 100644 --- a/src/plugins/data/server/search/strategies/sql_search/request_utils.ts +++ b/src/plugins/data/server/search/strategies/sql_search/request_utils.ts @@ -9,6 +9,10 @@ import { SqlGetAsyncRequest, SqlQueryRequest } from '@elastic/elasticsearch/lib/api/types'; import { ISearchOptions } from '../../../../common'; import { SearchSessionsConfigSchema } from '../../../../config'; +import { + getCommonDefaultAsyncGetParams, + getCommonDefaultAsyncSubmitParams, +} from '../common/async_utils'; /** @internal @@ -17,19 +21,8 @@ export function getDefaultAsyncSubmitParams( searchSessionsConfig: SearchSessionsConfigSchema | null, options: ISearchOptions ): Pick { - const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; - - const keepAlive = useSearchSessions - ? `${searchSessionsConfig!.defaultExpiration.asMilliseconds()}ms` - : '1m'; - return { - // Wait up to 100ms for the response to return - wait_for_completion_timeout: '100ms', - // If search sessions are used, store and get an async ID even for short running requests. - keep_on_completion: useSearchSessions, - // The initial keepalive is as defined in defaultExpiration if search sessions are used or 1m otherwise. - keep_alive: keepAlive, + ...getCommonDefaultAsyncSubmitParams(searchSessionsConfig, options), }; } @@ -40,17 +33,7 @@ export function getDefaultAsyncGetParams( searchSessionsConfig: SearchSessionsConfigSchema | null, options: ISearchOptions ): Pick { - const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId; - return { - // Wait up to 100ms for the response to return - wait_for_completion_timeout: '100ms', - ...(useSearchSessions - ? // Don't change the expiration of search requests that are tracked in a search session - undefined - : { - // We still need to do polling for searches not within the context of a search session or when search session disabled - keep_alive: '1m', - }), + ...getCommonDefaultAsyncGetParams(searchSessionsConfig, options), }; } diff --git a/src/plugins/data_view_management/public/components/edit_index_pattern/tabs/tabs.tsx b/src/plugins/data_view_management/public/components/edit_index_pattern/tabs/tabs.tsx index a50e1062f447f07..35323d90a7efb91 100644 --- a/src/plugins/data_view_management/public/components/edit_index_pattern/tabs/tabs.tsx +++ b/src/plugins/data_view_management/public/components/edit_index_pattern/tabs/tabs.tsx @@ -448,7 +448,7 @@ export function Tabs({ getFieldInfo, }} openModal={overlays.openModal} - theme={theme!} + theme={theme} userEditPermission={dataViews.getCanSaveSync()} /> )} diff --git a/src/plugins/data_view_management/public/components/field_editor/components/scripting_call_outs/__snapshots__/warning_call_out.test.tsx.snap b/src/plugins/data_view_management/public/components/field_editor/components/scripting_call_outs/__snapshots__/warning_call_out.test.tsx.snap index 5d417dadca923b5..9e68c1b787b76ab 100644 --- a/src/plugins/data_view_management/public/components/field_editor/components/scripting_call_outs/__snapshots__/warning_call_out.test.tsx.snap +++ b/src/plugins/data_view_management/public/components/field_editor/components/scripting_call_outs/__snapshots__/warning_call_out.test.tsx.snap @@ -1,224 +1,88 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ScriptingWarningCallOut should render normally 1`] = ` - - -
    -

    - - - , - "scriptsInAggregation": - - , - } - } +

    + + Familiarize yourself with + - - and - - - - before using this feature. Scripted fields can be used to display and aggregate calculated values. As such, they can be very slow and, if done incorrectly, can cause Kibana to become unusable. + scripted fields - -

    -
    -
    - + and + + before using this feature. Scripted fields can be used to display and aggregate calculated values. As such, they can be very slow and, if done incorrectly, can cause Kibana to become unusable. + +

    +
    , +
    , +
    - - +
    - - - - - Scripted fields are deprecated - - - -
    -
    - -
    - + + For greater flexibility and Painless script support, use + - - . - - -

    -
    - -
    - + + runtime fields + + + . + +

    - +
    - - -
    - - +
    , +
    , +] `; exports[`ScriptingWarningCallOut should render nothing if not visible 1`] = ` diff --git a/src/plugins/data_view_management/public/components/field_editor/components/scripting_call_outs/warning_call_out.test.tsx b/src/plugins/data_view_management/public/components/field_editor/components/scripting_call_outs/warning_call_out.test.tsx index 1233bb853f3a02d..c06226cfc252124 100644 --- a/src/plugins/data_view_management/public/components/field_editor/components/scripting_call_outs/warning_call_out.test.tsx +++ b/src/plugins/data_view_management/public/components/field_editor/components/scripting_call_outs/warning_call_out.test.tsx @@ -23,7 +23,7 @@ describe('ScriptingWarningCallOut', () => { }, }); - expect(component).toMatchSnapshot(); + expect(component.render()).toMatchSnapshot(); }); it('should render nothing if not visible', async () => { diff --git a/src/plugins/data_view_management/public/components/field_editor/components/scripting_help/test_script.scss b/src/plugins/data_view_management/public/components/field_editor/components/scripting_help/test_script.scss deleted file mode 100644 index ca230711827dc79..000000000000000 --- a/src/plugins/data_view_management/public/components/field_editor/components/scripting_help/test_script.scss +++ /dev/null @@ -1,5 +0,0 @@ -.testScript__searchBar { - .globalQueryBar { - padding: $euiSize 0 0; - } -} diff --git a/src/plugins/data_view_management/public/components/field_editor/components/scripting_help/test_script.tsx b/src/plugins/data_view_management/public/components/field_editor/components/scripting_help/test_script.tsx index 52bf88233169898..0eb0898f41b60a6 100644 --- a/src/plugins/data_view_management/public/components/field_editor/components/scripting_help/test_script.tsx +++ b/src/plugins/data_view_management/public/components/field_editor/components/scripting_help/test_script.tsx @@ -6,8 +6,6 @@ * Side Public License, v 1. */ -import './test_script.scss'; - import React, { Component, Fragment } from 'react'; import { @@ -223,8 +221,11 @@ export class TestScript extends Component { /> + +
    { - const { application, chrome, uiSettings, notifications, overlays } = coreMock.createStart(); + const { application, chrome, uiSettings, notifications, overlays, theme } = + coreMock.createStart(); const { http } = coreMock.createSetup(); const data = dataPluginMock.createStartContract(); const dataViewFieldEditor = indexPatternFieldEditorPluginMock.createStartContract(); @@ -81,6 +82,7 @@ const createIndexPatternManagmentContext = (): { IndexPatternEditor: indexPatternEditorPluginMock.createStartContract().IndexPatternEditorComponent, fieldFormats: fieldFormatsServiceMock.createStartContract(), + theme, }; }; diff --git a/src/plugins/data_view_management/public/types.ts b/src/plugins/data_view_management/public/types.ts index 56e9735a9001f13..0901ba72d050bb9 100644 --- a/src/plugins/data_view_management/public/types.ts +++ b/src/plugins/data_view_management/public/types.ts @@ -14,6 +14,7 @@ import { DocLinksStart, HttpSetup, ApplicationStart, + ThemeServiceStart, } from '@kbn/core/public'; import { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; @@ -44,6 +45,7 @@ export interface IndexPatternManagmentContext { IndexPatternEditor: DataViewEditorStart['IndexPatternEditorComponent']; fieldFormats: FieldFormatsStart; spaces?: SpacesPluginStart; + theme: ThemeServiceStart; } export type IndexPatternManagmentContextValue = diff --git a/src/plugins/discover/kibana.json b/src/plugins/discover/kibana.json index f9802782f8e48e1..cb40433b73fa1a4 100644 --- a/src/plugins/discover/kibana.json +++ b/src/plugins/discover/kibana.json @@ -17,7 +17,7 @@ "dataViewEditor" ], "optionalPlugins": ["home", "share", "usageCollection", "spaces", "triggersActionsUi"], - "requiredBundles": ["kibanaUtils", "kibanaReact", "dataViews"], + "requiredBundles": ["kibanaUtils", "kibanaReact", "dataViews", "unifiedSearch"], "extraPublicDirs": ["common"], "owner": { "name": "Data Discovery", diff --git a/src/plugins/discover/public/application/context/context_app.test.tsx b/src/plugins/discover/public/application/context/context_app.test.tsx index af3c83eb50d6c6f..828ec0d0eeb1a92 100644 --- a/src/plugins/discover/public/application/context/context_app.test.tsx +++ b/src/plugins/discover/public/application/context/context_app.test.tsx @@ -70,7 +70,8 @@ describe('ContextApp test', () => { const topNavProps = { appName: 'context', showSearchBar: true, - showQueryBar: false, + showQueryBar: true, + showQueryInput: false, showFilterBar: true, showSaveQuery: false, showDatePicker: false, diff --git a/src/plugins/discover/public/application/context/context_app.tsx b/src/plugins/discover/public/application/context/context_app.tsx index e84bbf644a89512..1f886fdacac6b97 100644 --- a/src/plugins/discover/public/application/context/context_app.tsx +++ b/src/plugins/discover/public/application/context/context_app.tsx @@ -133,7 +133,8 @@ export const ContextApp = ({ indexPattern, anchorId }: ContextAppProps) => { return { appName: 'context', showSearchBar: true, - showQueryBar: false, + showQueryBar: true, + showQueryInput: false, showFilterBar: true, showSaveQuery: false, showDatePicker: false, diff --git a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx index 955d69509cf01cc..7b715bb56a74c74 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx @@ -156,9 +156,9 @@ function DiscoverDocumentsComponent({ )} {!isLegacy && ( -
    - <> - + <> + +
    - -
    +
    + )} ); diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.scss b/src/plugins/discover/public/application/main/components/layout/discover_layout.scss index 1d074c002e340c3..9ea41f343b885ad 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.scss +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.scss @@ -29,15 +29,14 @@ discover-app { .dscPageBody__contents { overflow: hidden; - padding-top: $euiSizeXS / 2; // A little breathing room for the index pattern button } .dscPageContent__wrapper { - padding: 0 $euiSize $euiSize 0; + padding: $euiSizeS $euiSizeS $euiSizeS 0; overflow: hidden; // Ensures horizontal scroll of table @include euiBreakpoint('xs', 's') { - padding: 0 $euiSize $euiSize; + padding: 0 $euiSizeS $euiSizeS; } } diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx index ad1c96e308d12d2..6cbc8add99c39eb 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx @@ -226,6 +226,9 @@ export function DiscoverLayout({ stateContainer={stateContainer} updateQuery={onUpdateQuery} resetSavedSearch={resetSavedSearch} + onChangeIndexPattern={onChangeIndexPattern} + onEditRuntimeField={onEditRuntimeField} + useNewFieldsApi={useNewFieldsApi} /> - + - - - } - closePopover={[Function]} - data-test-subj="discover-addRuntimeField-popover" - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - > -
    -
    - - - -
    -
    -
    -
    - -`; diff --git a/src/plugins/discover/public/application/main/components/sidebar/change_indexpattern.test.tsx b/src/plugins/discover/public/application/main/components/sidebar/change_indexpattern.test.tsx deleted file mode 100644 index a5e93c1d895bce5..000000000000000 --- a/src/plugins/discover/public/application/main/components/sidebar/change_indexpattern.test.tsx +++ /dev/null @@ -1,71 +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 React from 'react'; -import { EuiSelectable } from '@elastic/eui'; -import { ShallowWrapper } from 'enzyme'; -import { act } from 'react-dom/test-utils'; -import { shallowWithIntl } from '@kbn/test-jest-helpers'; -import { ChangeIndexPattern } from './change_indexpattern'; -import { indexPatternMock } from '../../../../__mocks__/index_pattern'; -import { indexPatternWithTimefieldMock } from '../../../../__mocks__/index_pattern_with_timefield'; -import { IndexPatternRef } from './types'; - -function getProps() { - return { - indexPatternId: indexPatternMock.id, - indexPatternRefs: [ - indexPatternMock as IndexPatternRef, - indexPatternWithTimefieldMock as IndexPatternRef, - ], - onChangeIndexPattern: jest.fn(), - trigger: { - label: indexPatternMock.title, - title: indexPatternMock.title, - 'data-test-subj': 'indexPattern-switch-link', - }, - }; -} - -function getIndexPatternPickerList(instance: ShallowWrapper) { - return instance.find(EuiSelectable).first(); -} - -function getIndexPatternPickerOptions(instance: ShallowWrapper) { - return getIndexPatternPickerList(instance).prop('options'); -} - -export function selectIndexPatternPickerOption(instance: ShallowWrapper, selectedLabel: string) { - const options: Array<{ label: string; checked?: 'on' | 'off' }> = getIndexPatternPickerOptions( - instance - ).map((option: { label: string }) => - option.label === selectedLabel - ? { ...option, checked: 'on' } - : { ...option, checked: undefined } - ); - return getIndexPatternPickerList(instance).prop('onChange')!(options); -} - -describe('ChangeIndexPattern', () => { - test('switching index pattern to the same index pattern does not trigger onChangeIndexPattern', async () => { - const props = getProps(); - const comp = shallowWithIntl(); - await act(async () => { - selectIndexPatternPickerOption(comp, indexPatternMock.title); - }); - expect(props.onChangeIndexPattern).toHaveBeenCalledTimes(0); - }); - test('switching index pattern to a different index pattern triggers onChangeIndexPattern', async () => { - const props = getProps(); - const comp = shallowWithIntl(); - await act(async () => { - selectIndexPatternPickerOption(comp, indexPatternWithTimefieldMock.title); - }); - expect(props.onChangeIndexPattern).toHaveBeenCalledTimes(1); - expect(props.onChangeIndexPattern).toHaveBeenCalledWith(indexPatternWithTimefieldMock.id); - }); -}); diff --git a/src/plugins/discover/public/application/main/components/sidebar/change_indexpattern.tsx b/src/plugins/discover/public/application/main/components/sidebar/change_indexpattern.tsx deleted file mode 100644 index ceee905cff6fa05..000000000000000 --- a/src/plugins/discover/public/application/main/components/sidebar/change_indexpattern.tsx +++ /dev/null @@ -1,109 +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 { i18n } from '@kbn/i18n'; -import React, { useState } from 'react'; -import { - EuiButton, - EuiPopover, - EuiPopoverTitle, - EuiSelectable, - EuiButtonProps, -} from '@elastic/eui'; -import { EuiSelectableProps } from '@elastic/eui/src/components/selectable/selectable'; -import { IndexPatternRef } from './types'; - -export type ChangeIndexPatternTriggerProps = EuiButtonProps & { - label: string; - title?: string; -}; - -// TODO: refactor to shared component with ../../../../../../../../x-pack/legacy/plugins/lens/public/indexpattern_plugin/change_indexpattern - -export function ChangeIndexPattern({ - indexPatternId, - indexPatternRefs, - onChangeIndexPattern, - selectableProps, - trigger, -}: { - indexPatternId?: string; - indexPatternRefs: IndexPatternRef[]; - onChangeIndexPattern: (newId: string) => void; - selectableProps?: EuiSelectableProps<{ value: string }>; - trigger: ChangeIndexPatternTriggerProps; -}) { - const [isPopoverOpen, setPopoverIsOpen] = useState(false); - - const createTrigger = function () { - const { label, title, ...rest } = trigger; - return ( - setPopoverIsOpen(!isPopoverOpen)} - {...rest} - > - {label} - - ); - }; - - return ( - setPopoverIsOpen(false)} - display="block" - panelPaddingSize="s" - > -
    - - {i18n.translate('discover.fieldChooser.indexPattern.changeDataViewTitle', { - defaultMessage: 'Change data view', - })} - - - data-test-subj="indexPattern-switcher" - {...selectableProps} - searchable - singleSelection="always" - options={indexPatternRefs.map(({ title, id }) => ({ - label: title, - key: id, - value: id, - checked: id === indexPatternId ? 'on' : undefined, - }))} - onChange={(choices) => { - const choice = choices.find(({ checked }) => checked) as unknown as { - value: string; - }; - if (choice.value !== indexPatternId) { - onChangeIndexPattern(choice.value); - } - setPopoverIsOpen(false); - }} - searchProps={{ - compressed: true, - ...(selectableProps ? selectableProps.searchProps : undefined), - }} - > - {(list, search) => ( - <> - {search} - {list} - - )} - -
    -
    - ); -} diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_field_search.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_field_search.tsx index f79e45059e87e91..4bc2edb9c37ddfd 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_field_search.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_field_search.tsx @@ -409,10 +409,10 @@ export function DiscoverFieldSearch({ onChange, value, types, presentFieldTypes defaultMessage: 'Learn more about', })}   - +

    diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern.test.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern.test.tsx deleted file mode 100644 index d640e2fa1159470..000000000000000 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern.test.tsx +++ /dev/null @@ -1,95 +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 React from 'react'; -import { act } from 'react-dom/test-utils'; -import { shallowWithIntl as shallow } from '@kbn/test-jest-helpers'; -import { ShallowWrapper } from 'enzyme'; -import { ChangeIndexPattern } from './change_indexpattern'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { SavedObject } from '@kbn/core/server'; -import { DiscoverIndexPattern, DiscoverIndexPatternProps } from './discover_index_pattern'; -import { EuiSelectable } from '@elastic/eui'; -import type { DataView, DataViewAttributes } from '@kbn/data-views-plugin/public'; -import { indexPatternsMock } from '../../../../__mocks__/index_patterns'; - -const indexPattern = { - id: 'the-index-pattern-id-first', - title: 'test1 title', -} as DataView; - -const indexPattern1 = { - id: 'the-index-pattern-id-first', - attributes: { - title: 'test1 title', - }, -} as SavedObject; - -const indexPattern2 = { - id: 'the-index-pattern-id', - attributes: { - title: 'test2 title', - }, -} as SavedObject; - -const defaultProps = { - indexPatternList: [indexPattern1, indexPattern2], - selectedIndexPattern: indexPattern, - useNewFieldsApi: true, - indexPatterns: indexPatternsMock, - onChangeIndexPattern: jest.fn(), -}; - -function getIndexPatternPickerList(instance: ShallowWrapper) { - return instance.find(ChangeIndexPattern).first().dive().find(EuiSelectable); -} - -function getIndexPatternPickerOptions(instance: ShallowWrapper) { - return getIndexPatternPickerList(instance).prop('options'); -} - -function selectIndexPatternPickerOption(instance: ShallowWrapper, selectedLabel: string) { - const options: Array<{ label: string; checked?: 'on' | 'off' }> = getIndexPatternPickerOptions( - instance - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ).map((option: any) => - option.label === selectedLabel - ? { ...option, checked: 'on' } - : { ...option, checked: undefined } - ); - return getIndexPatternPickerList(instance).prop('onChange')!(options); -} - -describe('DiscoverIndexPattern', () => { - test('Invalid props dont cause an exception', () => { - const props = { - indexPatternList: null, - selectedIndexPattern: null, - onChangeIndexPattern: jest.fn(), - } as unknown as DiscoverIndexPatternProps; - - expect(shallow()).toMatchSnapshot(`""`); - }); - test('should list all index patterns', () => { - const instance = shallow(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect(getIndexPatternPickerOptions(instance)!.map((option: any) => option.label)).toEqual([ - 'test1 title', - 'test2 title', - ]); - }); - - test('should switch data panel to target index pattern', async () => { - const instance = shallow(); - await act(async () => { - selectIndexPatternPickerOption(instance, 'test2 title'); - }); - expect(defaultProps.onChangeIndexPattern).toHaveBeenCalledWith('the-index-pattern-id'); - }); -}); diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern.tsx deleted file mode 100644 index 83aa3ce478215c8..000000000000000 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern.tsx +++ /dev/null @@ -1,74 +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 React, { useState, useEffect } from 'react'; -import { SavedObject } from '@kbn/core/public'; -import type { DataView, DataViewAttributes } from '@kbn/data-views-plugin/public'; -import { IndexPatternRef } from './types'; -import { ChangeIndexPattern } from './change_indexpattern'; - -export interface DiscoverIndexPatternProps { - /** - * list of available index patterns, if length > 1, component offers a "change" link - */ - indexPatternList: Array>; - /** - * Callback function when changing an index pattern - */ - onChangeIndexPattern: (id: string) => void; - /** - * currently selected index pattern - */ - selectedIndexPattern: DataView; -} - -/** - * Component allows you to select an index pattern in discovers side bar - */ -export function DiscoverIndexPattern({ - indexPatternList, - onChangeIndexPattern, - selectedIndexPattern, -}: DiscoverIndexPatternProps) { - const options: IndexPatternRef[] = (indexPatternList || []).map((entity) => ({ - id: entity.id, - title: entity.attributes!.title, - })); - const { id: selectedId, title: selectedTitle } = selectedIndexPattern || {}; - - const [selected, setSelected] = useState({ - id: selectedId, - title: selectedTitle || '', - }); - useEffect(() => { - const { id, title } = selectedIndexPattern; - setSelected({ id, title }); - }, [selectedIndexPattern]); - if (!selectedId) { - return null; - } - - return ( - { - const indexPattern = options.find((pattern) => pattern.id === id); - if (indexPattern) { - onChangeIndexPattern(id); - setSelected(indexPattern); - } - }} - /> - ); -} diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern_management.test.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern_management.test.tsx deleted file mode 100644 index cddbe087030e7c4..000000000000000 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern_management.test.tsx +++ /dev/null @@ -1,118 +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 React from 'react'; -import { mountWithIntl, findTestSubject } from '@kbn/test-jest-helpers'; -import { EuiContextMenuPanel, EuiPopover, EuiContextMenuItem } from '@elastic/eui'; -import { DiscoverServices } from '../../../../build_services'; -import { DiscoverIndexPatternManagement } from './discover_index_pattern_management'; -import { stubLogstashIndexPattern } from '@kbn/data-plugin/common/stubs'; -import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; - -const mockServices = { - history: () => ({ - location: { - search: '', - }, - }), - capabilities: { - visualize: { - show: true, - }, - discover: { - save: false, - }, - }, - core: { - application: { - navigateToApp: jest.fn(), - }, - }, - uiSettings: { - get: (key: string) => { - if (key === 'fields:popularLimit') { - return 5; - } - }, - }, - dataViewFieldEditor: { - openEditor: jest.fn(), - userPermissions: { - editIndexPattern: () => { - return true; - }, - }, - }, -} as unknown as DiscoverServices; - -describe('Discover DataView Management', () => { - const indexPattern = stubLogstashIndexPattern; - - const editField = jest.fn(); - const createNewDataView = jest.fn(); - - const mountComponent = () => { - return mountWithIntl( - - - - ); - }; - - test('renders correctly', () => { - const component = mountComponent(); - expect(component).toMatchSnapshot(); - expect(component.find(EuiPopover).length).toBe(1); - }); - - test('click on a button opens popover', () => { - const component = mountComponent(); - expect(component.find(EuiContextMenuPanel).length).toBe(0); - - const button = findTestSubject(component, 'discoverIndexPatternActions'); - button.simulate('click'); - - expect(component.find(EuiContextMenuPanel).length).toBe(1); - expect(component.find(EuiContextMenuItem).length).toBe(3); - }); - - test('click on an add button executes editField callback', () => { - const component = mountComponent(); - const button = findTestSubject(component, 'discoverIndexPatternActions'); - button.simulate('click'); - - const addButton = findTestSubject(component, 'indexPattern-add-field'); - addButton.simulate('click'); - expect(editField).toHaveBeenCalledWith(undefined); - }); - - test('click on a manage button navigates away from discover', () => { - const component = mountComponent(); - const button = findTestSubject(component, 'discoverIndexPatternActions'); - button.simulate('click'); - - const manageButton = findTestSubject(component, 'indexPattern-manage-field'); - manageButton.simulate('click'); - expect(mockServices.core.application.navigateToApp).toHaveBeenCalled(); - }); - - test('click on add dataView button executes createNewDataView callback', () => { - const component = mountComponent(); - const button = findTestSubject(component, 'discoverIndexPatternActions'); - button.simulate('click'); - - const manageButton = findTestSubject(component, 'dataview-create-new'); - manageButton.simulate('click'); - expect(createNewDataView).toHaveBeenCalled(); - }); -}); diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern_management.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern_management.tsx deleted file mode 100644 index 823aa9c0050c053..000000000000000 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_index_pattern_management.tsx +++ /dev/null @@ -1,130 +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 React, { useState } from 'react'; -import { - EuiButtonIcon, - EuiContextMenuItem, - EuiContextMenuPanel, - EuiHorizontalRule, - EuiPopover, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import type { DataView } from '@kbn/data-views-plugin/public'; -import { useDiscoverServices } from '../../../../utils/use_discover_services'; - -export interface DiscoverIndexPatternManagementProps { - /** - * Currently selected index pattern - */ - selectedIndexPattern?: DataView; - /** - * Read from the Fields API - */ - useNewFieldsApi?: boolean; - /** - * Callback to execute on edit field action - * @param fieldName - */ - editField: (fieldName?: string) => void; - - /** - * Callback to execute on create new data action - */ - createNewDataView: () => void; -} - -export function DiscoverIndexPatternManagement(props: DiscoverIndexPatternManagementProps) { - const { dataViewFieldEditor, core } = useDiscoverServices(); - const { useNewFieldsApi, selectedIndexPattern, editField, createNewDataView } = props; - const dataViewEditPermission = dataViewFieldEditor?.userPermissions.editIndexPattern(); - const canEditDataViewField = !!dataViewEditPermission && useNewFieldsApi; - const [isAddIndexPatternFieldPopoverOpen, setIsAddIndexPatternFieldPopoverOpen] = useState(false); - - if (!useNewFieldsApi || !selectedIndexPattern || !canEditDataViewField) { - return null; - } - - const addField = () => { - editField(undefined); - }; - - return ( - { - setIsAddIndexPatternFieldPopoverOpen(false); - }} - ownFocus - data-test-subj="discover-addRuntimeField-popover" - button={ - { - setIsAddIndexPatternFieldPopoverOpen(!isAddIndexPatternFieldPopoverOpen); - }} - /> - } - > - { - setIsAddIndexPatternFieldPopoverOpen(false); - addField(); - }} - > - {i18n.translate('discover.fieldChooser.indexPatterns.addFieldButton', { - defaultMessage: 'Add field', - })} - , - { - setIsAddIndexPatternFieldPopoverOpen(false); - core.application.navigateToApp('management', { - path: `/kibana/indexPatterns/patterns/${props.selectedIndexPattern?.id}`, - }); - }} - > - {i18n.translate('discover.fieldChooser.indexPatterns.manageFieldButton', { - defaultMessage: 'Manage settings', - })} - , - , - { - setIsAddIndexPatternFieldPopoverOpen(false); - createNewDataView(); - }} - > - {i18n.translate('discover.fieldChooser.dataViews.createNewDataView', { - defaultMessage: 'Create new data view', - })} - , - ]} - /> - - ); -} diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.scss b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.scss index 9ef123fa1a60f3b..6845b1c89901d9f 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.scss +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.scss @@ -2,7 +2,7 @@ overflow: hidden; margin: 0 !important; flex-grow: 1; - padding-left: $euiSize; + padding: $euiSizeS 0 $euiSizeS $euiSizeS; width: $euiSize * 19; height: 100%; @@ -19,7 +19,7 @@ .dscSidebar__mobile { width: 100%; - padding: $euiSize $euiSize 0; + padding: $euiSizeS $euiSizeS 0; .dscSidebar__mobileBadge { margin-left: $euiSizeS; diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx index fb6af1bc1b77564..22f954e714987eb 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx @@ -20,16 +20,17 @@ import { EuiNotificationBadge, EuiPageSideBar, useResizeObserver, + EuiButton, } from '@elastic/eui'; import useShallowCompareEffect from 'react-use/lib/useShallowCompareEffect'; -import { isEqual, sortBy } from 'lodash'; +import { isEqual } from 'lodash'; import { FormattedMessage } from '@kbn/i18n-react'; import { indexPatterns as indexPatternUtils } from '@kbn/data-plugin/public'; +import { DataViewPicker } from '@kbn/unified-search-plugin/public'; import { DataViewField } from '@kbn/data-views-plugin/public'; import { useDiscoverServices } from '../../../../utils/use_discover_services'; import { DiscoverField } from './discover_field'; -import { DiscoverIndexPattern } from './discover_index_pattern'; import { DiscoverFieldSearch } from './discover_field_search'; import { FIELDS_LIMIT_SETTING } from '../../../../../common'; import { groupFields } from './lib/group_fields'; @@ -37,7 +38,6 @@ import { getDetails } from './lib/get_details'; import { FieldFilterState, getDefaultFieldFilter, setFieldFilterProp } from './lib/field_filter'; import { getIndexPatternFieldList } from './lib/get_index_pattern_field_list'; import { DiscoverSidebarResponsiveProps } from './discover_sidebar_responsive'; -import { DiscoverIndexPatternManagement } from './discover_index_pattern_management'; import { VIEW_MODE } from '../../../../components/view_mode_toggle'; import { ElasticSearchHit } from '../../../../types'; @@ -83,6 +83,8 @@ export interface DiscoverSidebarProps extends Omit(null); @@ -297,34 +299,6 @@ export function DiscoverSidebarComponent({ return null; } - if (useFlyout) { - return ( -
    - - - o.attributes.title)} - onChangeIndexPattern={onChangeIndexPattern} - /> - - - - - -
    - ); - } - return ( - - - - o.attributes.title)} - onChangeIndexPattern={onChangeIndexPattern} - /> - - - - - - + {Boolean(showDataViewPicker) && ( + + )}
    + + editField()} + size="s" + > + {i18n.translate('discover.fieldChooser.addField.label', { + defaultMessage: 'Add a field', + })} + + ); diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx index f2f58c43d5e7fac..f7664197ca98cd4 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx @@ -7,7 +7,6 @@ */ import React, { useEffect, useRef, useState, useCallback } from 'react'; -import { sortBy } from 'lodash'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { UiCounterMetricType } from '@kbn/analytics'; @@ -19,21 +18,16 @@ import { EuiBadge, EuiFlyoutHeader, EuiFlyout, - EuiSpacer, EuiIcon, EuiLink, EuiPortal, - EuiFlexGroup, - EuiFlexItem, } from '@elastic/eui'; import type { DataViewField, DataView, DataViewAttributes } from '@kbn/data-views-plugin/public'; import { SavedObject } from '@kbn/core/types'; import { useDiscoverServices } from '../../../../utils/use_discover_services'; -import { DiscoverIndexPattern } from './discover_index_pattern'; import { getDefaultFieldFilter } from './lib/field_filter'; import { DiscoverSidebar } from './discover_sidebar'; import { AppState } from '../../services/discover_state'; -import { DiscoverIndexPatternManagement } from './discover_index_pattern_management'; import { AvailableFields$, DataDocuments$ } from '../../utils/use_saved_search'; import { calcFieldCounts } from '../../utils/calc_field_counts'; import { VIEW_MODE } from '../../../../components/view_mode_toggle'; @@ -91,10 +85,6 @@ export interface DiscoverSidebarResponsiveProps { * @param eventName */ trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; - /** - * Shows index pattern and a button that displays the sidebar in a flyout - */ - useFlyout?: boolean; /** * Read from the Fields API */ @@ -124,13 +114,7 @@ export interface DiscoverSidebarResponsiveProps { */ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) { const services = useDiscoverServices(); - const { - selectedIndexPattern, - onEditRuntimeField, - useNewFieldsApi, - onChangeIndexPattern, - onDataViewCreated, - } = props; + const { selectedIndexPattern, onEditRuntimeField, useNewFieldsApi, onDataViewCreated } = props; const [fieldFilter, setFieldFilter] = useState(getDefaultFieldFilter()); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); /** @@ -291,34 +275,6 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) )}
    -
    - - - o.attributes.title)} - /> - - - - - -
    - -
    diff --git a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.test.tsx b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.test.tsx index 938d2d55df00475..7b8831f734279ed 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.test.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.test.tsx @@ -40,6 +40,8 @@ function getProps(savePermissions = true): DiscoverTopNavProps { onOpenInspector: jest.fn(), searchSource: {} as ISearchSource, resetSavedSearch: () => {}, + onEditRuntimeField: jest.fn(), + onChangeIndexPattern: jest.fn(), }; } diff --git a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx index 8656a2fdb70728a..87d2f04bd604b1f 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/discover_topnav.tsx @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useRef, useEffect } from 'react'; import { useHistory } from 'react-router-dom'; import { Query, TimeRange } from '@kbn/data-plugin/public'; import { DataViewType } from '@kbn/data-views-plugin/public'; @@ -25,6 +25,9 @@ export type DiscoverTopNavProps = Pick< updateQuery: (payload: { dateRange: TimeRange; query?: Query }, isUpdate?: boolean) => void; stateContainer: GetStateReturn; resetSavedSearch: () => void; + onChangeIndexPattern: (indexPattern: string) => void; + onEditRuntimeField: () => void; + useNewFieldsApi?: boolean; }; export const DiscoverTopNav = ({ @@ -38,6 +41,9 @@ export const DiscoverTopNav = ({ navigateTo, savedSearch, resetSavedSearch, + onChangeIndexPattern, + onEditRuntimeField, + useNewFieldsApi = false, }: DiscoverTopNavProps) => { const history = useHistory(); const showDatePicker = useMemo( @@ -45,7 +51,16 @@ export const DiscoverTopNav = ({ [indexPattern] ); const services = useDiscoverServices(); - const { TopNavMenu } = services.navigation.ui; + const { dataViewEditor, navigation, dataViewFieldEditor, data } = services; + const editPermission = useMemo( + () => dataViewFieldEditor.userPermissions.editIndexPattern(), + [dataViewFieldEditor] + ); + const canEditDataViewField = !!editPermission && useNewFieldsApi; + const closeFieldEditor = useRef<() => void | undefined>(); + const closeDataViewEditor = useRef<() => void | undefined>(); + + const { TopNavMenu } = navigation.ui; const onOpenSavedSearch = useCallback( (newSavedSearchId: string) => { @@ -58,6 +73,64 @@ export const DiscoverTopNav = ({ [history, resetSavedSearch, savedSearch.id] ); + useEffect(() => { + return () => { + // Make sure to close the editors when unmounting + if (closeFieldEditor.current) { + closeFieldEditor.current(); + } + if (closeDataViewEditor.current) { + closeDataViewEditor.current(); + } + }; + }, []); + + const editField = useMemo( + () => + canEditDataViewField + ? async (fieldName?: string, uiAction: 'edit' | 'add' = 'edit') => { + if (indexPattern?.id) { + const indexPatternInstance = await data.dataViews.get(indexPattern.id); + closeFieldEditor.current = dataViewFieldEditor.openEditor({ + ctx: { + dataView: indexPatternInstance, + }, + fieldName, + onSave: async () => { + onEditRuntimeField(); + }, + }); + } + } + : undefined, + [ + canEditDataViewField, + indexPattern?.id, + data.dataViews, + dataViewFieldEditor, + onEditRuntimeField, + ] + ); + + const addField = useMemo( + () => (canEditDataViewField && editField ? () => editField(undefined, 'add') : undefined), + [editField, canEditDataViewField] + ); + + const createNewDataView = useCallback(() => { + const indexPatternFieldEditPermission = dataViewEditor.userPermissions.editDataView; + if (!indexPatternFieldEditPermission) { + return; + } + closeDataViewEditor.current = dataViewEditor.openEditor({ + onSave: async (dataView) => { + if (dataView.id) { + onChangeIndexPattern(dataView.id); + } + }, + }); + }, [dataViewEditor, onChangeIndexPattern]); + const topNavMenu = useMemo( () => getTopNavLinks({ @@ -99,6 +172,18 @@ export const DiscoverTopNav = ({ return getHeaderActionMenuMounter(); }, []); + const dataViewPickerProps = { + trigger: { + label: indexPattern?.title || '', + 'data-test-subj': 'discover-dataView-switch-link', + title: indexPattern?.title || '', + }, + currentDataViewId: indexPattern?.id, + onAddField: addField, + onDataViewCreated: createNewDataView, + onChangeDataView: (newIndexPatternId: string) => onChangeIndexPattern(newIndexPatternId), + }; + return ( ); }; diff --git a/src/plugins/discover/public/application/main/discover_main_app.test.tsx b/src/plugins/discover/public/application/main/discover_main_app.test.tsx index ceb06df058faee6..d2f0c7e2dd00585 100644 --- a/src/plugins/discover/public/application/main/discover_main_app.test.tsx +++ b/src/plugins/discover/public/application/main/discover_main_app.test.tsx @@ -9,11 +9,11 @@ import React from 'react'; import { mountWithIntl } from '@kbn/test-jest-helpers'; import { indexPatternMock } from '../../__mocks__/index_pattern'; import { DiscoverMainApp } from './discover_main_app'; +import { DiscoverTopNav } from './components/top_nav/discover_topnav'; import { savedSearchMock } from '../../__mocks__/saved_search'; import { SavedObject } from '@kbn/core/types'; import type { DataViewAttributes } from '@kbn/data-views-plugin/public'; import { setHeaderActionMenuMounter } from '../../kibana_services'; -import { findTestSubject } from '@elastic/eui/lib/test'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { discoverServiceMock } from '../../__mocks__/services'; import { Router } from 'react-router-dom'; @@ -42,8 +42,7 @@ describe('DiscoverMainApp', () => { ); - expect(findTestSubject(component, 'indexPattern-switch-link').text()).toBe( - indexPatternMock.title - ); + expect(component.find(DiscoverTopNav).exists()).toBe(true); + expect(component.find(DiscoverTopNav).prop('indexPattern')).toEqual(indexPatternMock); }); }); diff --git a/src/plugins/discover/public/components/discover_grid/constants.ts b/src/plugins/discover/public/components/discover_grid/constants.ts index d026607aef3730b..f2f5a8e8bebc75f 100644 --- a/src/plugins/discover/public/components/discover_grid/constants.ts +++ b/src/plugins/discover/public/components/discover_grid/constants.ts @@ -19,7 +19,7 @@ export const GRID_STYLE = { export const pageSizeArr = [25, 50, 100, 250]; export const defaultPageSize = 100; -export const defaultTimeColumnWidth = 190; +export const defaultTimeColumnWidth = 210; export const toolbarVisibility = { showColumnSelector: { allowHide: false, diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid.scss b/src/plugins/discover/public/components/discover_grid/discover_grid.scss index 0204433a5ba1c3f..113bb6092485000 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid.scss +++ b/src/plugins/discover/public/components/discover_grid/discover_grid.scss @@ -30,6 +30,15 @@ } } +.dscDiscoverGrid__cellValue { + font-family: $euiCodeFontFamily; +} + +.dscDiscoverGrid__cellPopoverValue { + font-family: $euiCodeFontFamily; + font-size: $euiFontSizeS; +} + .dscDiscoverGrid__footer { background-color: $euiColorLightShade; padding: $euiSize / 2 $euiSize; diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_columns.test.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_columns.test.tsx index a9116e616946f78..c98db31a97f7f8b 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid_columns.test.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_columns.test.tsx @@ -207,7 +207,7 @@ describe('Discover grid columns', function () { /> , "id": "timestamp", - "initialWidth": 190, + "initialWidth": 210, "isSortable": true, "schema": "datetime", }, 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 be4c69f1ced25ca..53e5c23cb47d581 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 @@ -92,7 +92,9 @@ describe('Discover grid cell rendering', function () { setCellProps={jest.fn()} /> ); - expect(component.html()).toMatchInlineSnapshot(`"100"`); + expect(component.html()).toMatchInlineSnapshot( + `"100"` + ); }); it('renders bytes column correctly using _source when details is true', () => { @@ -115,7 +117,9 @@ describe('Discover grid cell rendering', function () { setCellProps={jest.fn()} /> ); - expect(component.html()).toMatchInlineSnapshot(`"100"`); + expect(component.html()).toMatchInlineSnapshot( + `"100"` + ); }); it('renders bytes column correctly using fields when details is true', () => { @@ -138,7 +142,9 @@ describe('Discover grid cell rendering', function () { setCellProps={jest.fn()} /> ); - expect(component.html()).toMatchInlineSnapshot(`"100"`); + expect(component.html()).toMatchInlineSnapshot( + `"100"` + ); }); it('renders _source column correctly', () => { @@ -163,7 +169,7 @@ describe('Discover grid cell rendering', function () { ); expect(component).toMatchInlineSnapshot(` @@ -280,7 +286,7 @@ describe('Discover grid cell rendering', function () { ); expect(component).toMatchInlineSnapshot(` @@ -359,7 +365,7 @@ describe('Discover grid cell rendering', function () { ); expect(component).toMatchInlineSnapshot(` @@ -485,7 +491,7 @@ describe('Discover grid cell rendering', function () { ); expect(component).toMatchInlineSnapshot(` @@ -527,7 +533,7 @@ describe('Discover grid cell rendering', function () { ); expect(component).toMatchInlineSnapshot(` @@ -603,6 +609,7 @@ describe('Discover grid cell rendering', function () { ); expect(component).toMatchInlineSnapshot(` ); - expect(component.html()).toMatchInlineSnapshot(`"-"`); + expect(component.html()).toMatchInlineSnapshot( + `"-"` + ); }); it('renders correctly when invalid column is given', () => { @@ -657,7 +666,9 @@ describe('Discover grid cell rendering', function () { setCellProps={jest.fn()} /> ); - expect(component.html()).toMatchInlineSnapshot(`"-"`); + expect(component.html()).toMatchInlineSnapshot( + `"-"` + ); }); it('renders unmapped fields correctly', () => { @@ -695,6 +706,7 @@ describe('Discover grid cell rendering', function () { ); expect(component).toMatchInlineSnapshot(` -; + return -; } /** @@ -102,7 +105,11 @@ export const getRenderCellValueFn = : formatHit(row, dataView, fieldsToShow, maxEntries, fieldFormats); return ( - + {pairs.map(([key, value]) => ( {key} @@ -118,6 +125,7 @@ export const getRenderCellValueFn = return ( - - Could not fetch data at this time. Refresh the tab to try again. - - - Refresh - -
    - } - iconType="alert" - title={ -

    - An Error Occurred -

    - } +
    - + +
    +
    -
    + An Error Occurred + +
    - - - -
    + class="euiSpacer euiSpacer--m css-hg1jdf-euiSpacer-m" + />
    -
    - -

    - An Error Occurred -

    -
    - + Could not fetch data at this time. Refresh the tab to try again. +
    + - - -
    -
    - + Refresh + - +
    -
    +
    - - - +
    +
    +
    `; exports[`Source Viewer component renders json code editor 1`] = ` @@ -258,8 +150,91 @@ exports[`Source Viewer component renders json code editor 1`] = ` size="s" >
    + css="unknown styles" + > + + + + , + "ctr": 2, + "insertionPoint": undefined, + "isSpeedy": false, + "key": "css", + "nonce": undefined, + "prepend": undefined, + "tags": Array [ + , + , + ], + }, + } + } + isStringTag={true} + serialized={ + Object { + "map": "/*# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3NyYy9jb21wb25lbnRzL3NwYWNlci9zcGFjZXIuc3R5bGVzLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQW9CUSIsImZpbGUiOiIuLi8uLi8uLi9zcmMvY29tcG9uZW50cy9zcGFjZXIvc3BhY2VyLnN0eWxlcy50cyIsInNvdXJjZXNDb250ZW50IjpbIi8qXG4gKiBDb3B5cmlnaHQgRWxhc3RpY3NlYXJjaCBCLlYuIGFuZC9vciBsaWNlbnNlZCB0byBFbGFzdGljc2VhcmNoIEIuVi4gdW5kZXIgb25lXG4gKiBvciBtb3JlIGNvbnRyaWJ1dG9yIGxpY2Vuc2UgYWdyZWVtZW50cy4gTGljZW5zZWQgdW5kZXIgdGhlIEVsYXN0aWMgTGljZW5zZVxuICogMi4wIGFuZCB0aGUgU2VydmVyIFNpZGUgUHVibGljIExpY2Vuc2UsIHYgMTsgeW91IG1heSBub3QgdXNlIHRoaXMgZmlsZSBleGNlcHRcbiAqIGluIGNvbXBsaWFuY2Ugd2l0aCwgYXQgeW91ciBlbGVjdGlvbiwgdGhlIEVsYXN0aWMgTGljZW5zZSAyLjAgb3IgdGhlIFNlcnZlclxuICogU2lkZSBQdWJsaWMgTGljZW5zZSwgdiAxLlxuICovXG5cbmltcG9ydCB7IGNzcyB9IGZyb20gJ0BlbW90aW9uL3JlYWN0JztcbmltcG9ydCB7IFVzZUV1aVRoZW1lIH0gZnJvbSAnLi4vLi4vc2VydmljZXMnO1xuXG5leHBvcnQgY29uc3QgZXVpU3BhY2VyU3R5bGVzID0gKHsgZXVpVGhlbWUgfTogVXNlRXVpVGhlbWUpID0+ICh7XG4gIC8vIGJhc2VcbiAgZXVpU3BhY2VyOiBjc3NgXG4gICAgZmxleC1zaHJpbms6IDA7IC8vIGRvbid0IGV2ZXIgbGV0IHRoaXMgc2hyaW5rIGluIGhlaWdodCBpZiBkaXJlY3QgZGVzY2VuZGVudCBvZiBmbGV4O1xuICBgLFxuICAvLyB2YXJpYW50c1xuICB4czogY3NzYFxuICAgIGhlaWdodDogJHtldWlUaGVtZS5zaXplLnhzfTtcbiAgYCxcbiAgczogY3NzYFxuICAgIGhlaWdodDogJHtldWlUaGVtZS5zaXplLnN9O1xuICBgLFxuICBtOiBjc3NgXG4gICAgaGVpZ2h0OiAke2V1aVRoZW1lLnNpemUuYmFzZX07XG4gIGAsXG4gIGw6IGNzc2BcbiAgICBoZWlnaHQ6ICR7ZXVpVGhlbWUuc2l6ZS5sfTtcbiAgYCxcbiAgeGw6IGNzc2BcbiAgICBoZWlnaHQ6ICR7ZXVpVGhlbWUuc2l6ZS54bH07XG4gIGAsXG4gIHh4bDogY3NzYFxuICAgIGhlaWdodDogJHtldWlUaGVtZS5zaXplLnh4bH07XG4gIGAsXG59KTtcbiJdfQ== */", + "name": "i2qclb-euiSpacer-s", + "next": undefined, + "styles": "flex-shrink:0;label:euiSpacer;;;height:8px;;label:s;;;", + "toString": [Function], + } + } + /> +
    +
    { /> ); - expect(comp.children()).toMatchSnapshot(); + expect(comp.children().render()).toMatchSnapshot(); const errorPrompt = comp.find(EuiEmptyPrompt); expect(errorPrompt.length).toBe(1); const refreshButton = comp.find(EuiButton); diff --git a/src/plugins/discover/server/saved_objects/search_migrations.test.ts b/src/plugins/discover/server/saved_objects/search_migrations.test.ts index 9563bd6dc86c3b6..fcce5d41fe90b06 100644 --- a/src/plugins/discover/server/saved_objects/search_migrations.test.ts +++ b/src/plugins/discover/server/saved_objects/search_migrations.test.ts @@ -350,6 +350,7 @@ Object { testMigrateMatchAllQuery(migrationFn); }); }); + it('should apply search source migrations within saved search', () => { const savedSearch = { attributes: { @@ -379,4 +380,27 @@ Object { }, }); }); + + it('should not apply search source migrations within saved search when searchSourceJSON is not an object', () => { + const savedSearch = { + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: '5', + }, + }, + } as SavedObjectUnsanitizedDoc; + + const versionToTest = '9.1.2'; + const migrations = getAllMigrations({ + [versionToTest]: (state) => ({ ...state, migrated: true }), + }); + + expect(migrations[versionToTest](savedSearch, {} as SavedObjectMigrationContext)).toEqual({ + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: '5', + }, + }, + }); + }); }); diff --git a/src/plugins/discover/server/saved_objects/search_migrations.ts b/src/plugins/discover/server/saved_objects/search_migrations.ts index 95da82fa38acfa7..2fb49628f53bcc8 100644 --- a/src/plugins/discover/server/saved_objects/search_migrations.ts +++ b/src/plugins/discover/server/saved_objects/search_migrations.ts @@ -17,7 +17,7 @@ import type { import { mergeSavedObjectMigrationMaps } from '@kbn/core/server'; import { DEFAULT_QUERY_LANGUAGE } from '@kbn/data-plugin/server'; import { MigrateFunctionsObject, MigrateFunction } from '@kbn/kibana-utils-plugin/common'; -import type { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import { isSerializedSearchSource, SerializedSearchSourceFields } from '@kbn/data-plugin/common'; export interface SavedSearchMigrationAttributes extends SavedObjectAttributes { kibanaSavedObjectMeta: { @@ -135,27 +135,31 @@ const migrateSearchSortToNestedArray: SavedObjectMigrationFn = (doc) = /** * This creates a migration map that applies search source migrations */ -const getSearchSourceMigrations = (searchSourceMigrations: MigrateFunctionsObject) => +const getSearchSourceMigrations = ( + searchSourceMigrations: MigrateFunctionsObject +): MigrateFunctionsObject => mapValues( searchSourceMigrations, (migrate: MigrateFunction): MigrateFunction => (state) => { - const _state = state as unknown as { attributes: SavedSearchMigrationAttributes }; - - const parsedSearchSourceJSON = _state.attributes.kibanaSavedObjectMeta.searchSourceJSON; - - if (!parsedSearchSourceJSON) return _state; - - return { - ..._state, - attributes: { - ..._state.attributes, - kibanaSavedObjectMeta: { - ..._state.attributes.kibanaSavedObjectMeta, - searchSourceJSON: JSON.stringify(migrate(JSON.parse(parsedSearchSourceJSON))), + const _state = state as { attributes: SavedSearchMigrationAttributes }; + + const parsedSearchSourceJSON = JSON.parse( + _state.attributes.kibanaSavedObjectMeta.searchSourceJSON + ); + if (isSerializedSearchSource(parsedSearchSourceJSON)) { + return { + ..._state, + attributes: { + ..._state.attributes, + kibanaSavedObjectMeta: { + ..._state.attributes.kibanaSavedObjectMeta, + searchSourceJSON: JSON.stringify(migrate(parsedSearchSourceJSON)), + }, }, - }, - }; + }; + } + return _state; } ); @@ -171,6 +175,6 @@ export const getAllMigrations = ( ): SavedObjectMigrationMap => { return mergeSavedObjectMigrationMaps( searchMigrations, - getSearchSourceMigrations(searchSourceMigrations) as unknown as SavedObjectMigrationMap + getSearchSourceMigrations(searchSourceMigrations) as SavedObjectMigrationMap ); }; diff --git a/src/plugins/discover/tsconfig.json b/src/plugins/discover/tsconfig.json index 817e73f16617e64..9915680ada26e89 100644 --- a/src/plugins/discover/tsconfig.json +++ b/src/plugins/discover/tsconfig.json @@ -25,6 +25,7 @@ { "path": "../data_view_field_editor/tsconfig.json"}, { "path": "../field_formats/tsconfig.json" }, { "path": "../data_views/tsconfig.json" }, + { "path": "../unified_search/tsconfig.json" }, { "path": "../../../x-pack/plugins/spaces/tsconfig.json" }, { "path": "../data_view_editor/tsconfig.json" }, { "path": "../../../x-pack/plugins/triggers_actions_ui/tsconfig.json" } diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index 88ff7f196f98421..a6a276d440dfa4e 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -132,6 +132,37 @@ export abstract class Container< return this.createAndSaveEmbeddable(type, panelState); } + public async replaceEmbeddable< + EEI extends EmbeddableInput = EmbeddableInput, + EEO extends EmbeddableOutput = EmbeddableOutput, + E extends IEmbeddable = IEmbeddable + >(id: string, newExplicitInput: Partial, newType?: string) { + if (!this.input.panels[id]) { + throw new PanelNotFoundError(); + } + + if (newType && newType !== this.input.panels[id].type) { + const factory = this.getFactory(newType) as EmbeddableFactory | undefined; + if (!factory) { + throw new EmbeddableFactoryNotFoundError(newType); + } + this.updateInput({ + panels: { + ...this.input.panels, + [id]: { + ...this.input.panels[id], + explicitInput: { ...newExplicitInput, id }, + type: newType, + }, + }, + } as Partial); + } else { + this.updateInputForChild(id, newExplicitInput); + } + + await this.untilEmbeddableLoaded(id); + } + public removeEmbeddable(embeddableId: string) { // Just a shortcut for removing the panel from input state, all internal state will get cleaned up naturally // by the listener. diff --git a/src/plugins/embeddable/public/lib/containers/i_container.ts b/src/plugins/embeddable/public/lib/containers/i_container.ts index f082000b38d4bca..5539f854b24d9c7 100644 --- a/src/plugins/embeddable/public/lib/containers/i_container.ts +++ b/src/plugins/embeddable/public/lib/containers/i_container.ts @@ -98,4 +98,14 @@ export interface IContainer< type: string, explicitInput: Partial ): Promise; + + replaceEmbeddable< + EEI extends EmbeddableInput = EmbeddableInput, + EEO extends EmbeddableOutput = EmbeddableOutput, + E extends Embeddable = Embeddable + >( + id: string, + newExplicitInput: Partial, + newType?: string + ): void; } diff --git a/src/plugins/es_ui_shared/public/components/view_api_request_flyout/__snapshots__/view_api_request_flyout.test.tsx.snap b/src/plugins/es_ui_shared/public/components/view_api_request_flyout/__snapshots__/view_api_request_flyout.test.tsx.snap index 2d850ee8082f95d..7d957737284c368 100644 --- a/src/plugins/es_ui_shared/public/components/view_api_request_flyout/__snapshots__/view_api_request_flyout.test.tsx.snap +++ b/src/plugins/es_ui_shared/public/components/view_api_request_flyout/__snapshots__/view_api_request_flyout.test.tsx.snap @@ -41,10 +41,10 @@ exports[`ViewApiRequestFlyout is rendered 1`] = `

    diff --git a/src/plugins/expressions/common/executor/executor.test.ts b/src/plugins/expressions/common/executor/executor.test.ts
    index 35f0b9c13aa1a4a..ea7116a5307ba84 100644
    --- a/src/plugins/expressions/common/executor/executor.test.ts
    +++ b/src/plugins/expressions/common/executor/executor.test.ts
    @@ -141,13 +141,15 @@ describe('Executor', () => {
           inject: (state: ExpressionAstFunction['arguments']) => {
             return injectFn(state);
           },
    -      migrations: {
    -        '7.10.0': ((state: ExpressionAstFunction, version: string): ExpressionAstFunction => {
    -          return migrateFn(state, version);
    -        }) as unknown as MigrateFunction,
    -        '7.10.1': ((state: ExpressionAstFunction, version: string): ExpressionAstFunction => {
    -          return migrateFn(state, version);
    -        }) as unknown as MigrateFunction,
    +      migrations: () => {
    +        return {
    +          '7.10.0': ((state: ExpressionAstFunction, version: string): ExpressionAstFunction => {
    +            return migrateFn(state, version);
    +          }) as unknown as MigrateFunction,
    +          '7.10.1': ((state: ExpressionAstFunction, version: string): ExpressionAstFunction => {
    +            return migrateFn(state, version);
    +          }) as unknown as MigrateFunction,
    +        };
           },
           fn: jest.fn(),
         };
    diff --git a/src/plugins/expressions/common/executor/executor.ts b/src/plugins/expressions/common/executor/executor.ts
    index 0a5e8d388fe0084..4071f8f7f003fca 100644
    --- a/src/plugins/expressions/common/executor/executor.ts
    +++ b/src/plugins/expressions/common/executor/executor.ts
    @@ -336,7 +336,11 @@ export class Executor = Record Object.keys(fn.migrations))
    +        .map((fn) => {
    +          const migrations =
    +            typeof fn.migrations === 'function' ? fn.migrations() : fn.migrations || {};
    +          return Object.keys(migrations);
    +        })
             .flat(1)
         );
     
    diff --git a/src/plugins/expressions/common/expression_types/specs/datatable.ts b/src/plugins/expressions/common/expression_types/specs/datatable.ts
    index 0e1e76acddca4d7..38b21addd596851 100644
    --- a/src/plugins/expressions/common/expression_types/specs/datatable.ts
    +++ b/src/plugins/expressions/common/expression_types/specs/datatable.ts
    @@ -186,7 +186,7 @@ export const datatable: ExpressionTypeDefinition {
    -        return { id: colName, name: colName, meta: { type: val.type } };
    +        return { id: colName, name: val.expression, meta: { type: val.type } };
           }),
         }),
       },
    diff --git a/src/plugins/expressions/common/expression_types/specs/pointseries.ts b/src/plugins/expressions/common/expression_types/specs/pointseries.ts
    index ef2079bd387a04a..b343e91fe707b84 100644
    --- a/src/plugins/expressions/common/expression_types/specs/pointseries.ts
    +++ b/src/plugins/expressions/common/expression_types/specs/pointseries.ts
    @@ -6,16 +6,25 @@
      * Side Public License, v 1.
      */
     
    +import { $Values } from '@kbn/utility-types';
     import { ExpressionTypeDefinition, ExpressionValueBoxed } from '../types';
     import { Datatable, DatatableRow } from './datatable';
     import { ExpressionValueRender } from './render';
     
     const name = 'pointseries';
     
    +export const PointSeriesColumnNames = {
    +  X: 'x',
    +  Y: 'y',
    +  COLOR: 'color',
    +  SIZE: 'size',
    +  TEXT: 'text',
    +} as const;
    +
     /**
      * Allowed column names in a PointSeries
      */
    -export type PointSeriesColumnName = 'x' | 'y' | 'color' | 'size' | 'text';
    +export type PointSeriesColumnName = $Values;
     
     /**
      * Column in a PointSeries
    diff --git a/src/plugins/expressions/common/index.ts b/src/plugins/expressions/common/index.ts
    index dcacf4926543063..110fc2f5594f9a6 100644
    --- a/src/plugins/expressions/common/index.ts
    +++ b/src/plugins/expressions/common/index.ts
    @@ -91,6 +91,7 @@ export {
       unboxExpressionValue,
       isDatatable,
       ExpressionType,
    +  PointSeriesColumnNames,
     } from './expression_types';
     export type {
       AnyExpressionTypeDefinition,
    diff --git a/src/plugins/home/public/application/components/tutorial/__snapshots__/saved_objects_installer.test.js.snap b/src/plugins/home/public/application/components/tutorial/__snapshots__/saved_objects_installer.test.js.snap
    index d970dd5416816b4..8527a9a109647b5 100644
    --- a/src/plugins/home/public/application/components/tutorial/__snapshots__/saved_objects_installer.test.js.snap
    +++ b/src/plugins/home/public/application/components/tutorial/__snapshots__/saved_objects_installer.test.js.snap
    @@ -1,475 +1,125 @@
     // Jest Snapshot v1, https://goo.gl/fbAQLP
     
     exports[`bulkCreate should display error message when bulkCreate request fails 1`] = `
    -
    -  
    -    

    - Load Kibana objects -

    -
    - , +
    - -
    - -
    -

    - Imports index pattern, visualizations and pre-defined dashboards. -

    -
    -
    -
    -
    - +

    + Imports index pattern, visualizations and pre-defined dashboards. +

    +
    +
    +
    + - - -
    - + Load Kibana objects + + +
    - - -
    - - , +
    , +
    -
    - - Request failed, Error: simulated bulkRequest error - -
    + Request failed, Error: simulated bulkRequest error +
    - - +
    , +] `; exports[`bulkCreate should display success message when bulkCreate is successful 1`] = ` - - -

    - Load Kibana objects -

    -
    - , +
    - -
    - -
    -

    - Imports index pattern, visualizations and pre-defined dashboards. -

    -
    -
    -
    -
    - +

    + Imports index pattern, visualizations and pre-defined dashboards. +

    +
    +
    +
    + - - -
    - + Load Kibana objects + + +
    - - -
    - - , +
    , +
    -
    - - 1 saved objects successfully added - -
    + 1 saved objects successfully added +
    - - +
    , +] `; exports[`renders 1`] = ` diff --git a/src/plugins/home/public/application/components/tutorial/saved_objects_installer.test.js b/src/plugins/home/public/application/components/tutorial/saved_objects_installer.test.js index 67ae2d1dd2eed0d..27dad0f378ab2a7 100644 --- a/src/plugins/home/public/application/components/tutorial/saved_objects_installer.test.js +++ b/src/plugins/home/public/application/components/tutorial/saved_objects_installer.test.js @@ -45,7 +45,7 @@ describe('bulkCreate', () => { // Ensure the state changes are reflected component.update(); - expect(component).toMatchSnapshot(); + expect(component.render()).toMatchSnapshot(); }); test('should display error message when bulkCreate request fails', async () => { @@ -66,7 +66,7 @@ describe('bulkCreate', () => { // Ensure the state changes are reflected component.update(); - expect(component).toMatchSnapshot(); + expect(component.render()).toMatchSnapshot(); }); test('should filter out saved object version before calling bulkCreate', async () => { diff --git a/src/plugins/inspector/public/views/requests/index.ts b/src/plugins/inspector/public/views/requests/index.ts index ce1cb766a298543..37d446958e8b285 100644 --- a/src/plugins/inspector/public/views/requests/index.ts +++ b/src/plugins/inspector/public/views/requests/index.ts @@ -20,7 +20,7 @@ export const getRequestsViewDescription = (): InspectorViewDescription => ({ }), order: 20, help: i18n.translate('inspector.requests.requestsDescriptionTooltip', { - defaultMessage: 'View the requests that collected the data', + defaultMessage: 'View the search requests used to collect the data', }), shouldShow(adapters: Adapters) { return Boolean(adapters.requests); diff --git a/src/plugins/kibana_usage_collection/README.md b/src/plugins/kibana_usage_collection/README.md index 4ea014457fd07b3..08e830fba41555d 100644 --- a/src/plugins/kibana_usage_collection/README.md +++ b/src/plugins/kibana_usage_collection/README.md @@ -2,17 +2,18 @@ This plugin registers the Platform Usage Collectors in Kibana. -| Collector name | Description | Extended documentation | -|----------------|:------------|:----------------------:| -| **Application Usage** | Measures how popular an App in Kibana is by reporting the on-screen time and the number of general clicks that happen in it. | [Link](./server/collectors/application_usage/README.md) | -| **Core Metrics** | Collects the usage reported by the core APIs | - | -| **Config Usage** | Reports the non-default values set via `kibana.yml` config file or CLI options. It `[redacts]` any potential PII-sensitive values. | [Link](./server/collectors/config_usage/README.md) | -| **User-changed UI Settings** | Reports all the UI Settings that have been overwritten by the user. It `[redacts]` any potential PII-sensitive values. | [Link](./server/collectors/management/README.md) | -| **CSP configuration** | Reports the key values regarding the CSP configuration. | - | -| **Kibana** | It reports the number of Saved Objects per type. It is limited to `dashboard`, `visualization`, `search`, `index-pattern`, `graph-workspace`.
    It exists for legacy purposes, and may still be used by Monitoring via Metricbeat. | - | -| **Saved Objects Counts** | Number of Saved Objects per type. | - | -| **Localization data** | Localization settings: setup locale and installed translation files. | - | -| **Ops stats** | Operation metrics from the system. | - | -| **UI Counters** | Daily aggregation of the number of times an event occurs in the UI. | [Link](../usage_collection/README.mdx#ui-counters) | -| **UI Metrics** | Deprecated. Old form of UI Counters. It reports the _count of the repetitions since the cluster's first start_ of any UI events that may have happened. | - | -| **Usage Counters** | Daily aggregation of the number of times an event occurs on the Server. | [Link](../usage_collection/README.mdx#usage-counters) | +| Collector name | Description | Extended documentation | +|--------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:----------------------------------------------------------------------------------------:| +| **Application Usage** | Measures how popular an App in Kibana is by reporting the on-screen time and the number of general clicks that happen in it. | [Link](./server/collectors/application_usage/README.md) | +| **Core Metrics** | Collects the usage reported by the core APIs | - | +| **Config Usage** | Reports the non-default values set via `kibana.yml` config file or CLI options. It `[redacts]` any potential PII-sensitive values. | [Link](./server/collectors/config_usage/README.md) | +| **User-changed UI Settings** | Reports all the UI Settings that have been overwritten by the user. It `[redacts]` any potential PII-sensitive values. | [Link](./server/collectors/management/README.md) | +| **CSP configuration** | Reports the key values regarding the CSP configuration. | - | +| **Kibana** | It reports the number of Saved Objects per type. It is limited to `dashboard`, `visualization`, `search`, `index-pattern`, `graph-workspace`.
    It exists for legacy purposes, and may still be used by Monitoring via Metricbeat. | - | +| **Saved Objects Counts** | Number of Saved Objects per type. | - | +| **Localization data** | Localization settings: setup locale and installed translation files. | - | +| **Ops stats** | Operation metrics from the system. | - | +| **UI Counters** | Daily aggregation of the number of times an event occurs in the UI. | [Link](../usage_collection/README.mdx#ui-counters) | +| **UI Metrics** | Deprecated. Old form of UI Counters. It reports the _count of the repetitions since the cluster's first start_ of any UI events that may have happened. | - | +| **Usage Counters** | Daily aggregation of the number of times an event occurs on the Server. | [Link](../usage_collection/README.mdx#usage-counters) | +| **Event-based Telemetry Success Counters** | Using the UI and Usage Counters APIs, it reports the stats coming out of the `core.analytics.telemetryCounters$` observable. | [Browser](./public/ebt_counters/README.md) and [Server](./server/ebt_counters/README.md) | diff --git a/src/plugins/kibana_usage_collection/kibana.json b/src/plugins/kibana_usage_collection/kibana.json index 39b55e5c6dd9469..41fc5c6c37b7830 100644 --- a/src/plugins/kibana_usage_collection/kibana.json +++ b/src/plugins/kibana_usage_collection/kibana.json @@ -6,7 +6,7 @@ }, "version": "kibana", "server": true, - "ui": false, + "ui": true, "requiredPlugins": [ "usageCollection" ], diff --git a/src/plugins/kibana_usage_collection/public/ebt_counters/README.md b/src/plugins/kibana_usage_collection/public/ebt_counters/README.md new file mode 100644 index 000000000000000..d30aa0661e977f8 --- /dev/null +++ b/src/plugins/kibana_usage_collection/public/ebt_counters/README.md @@ -0,0 +1,14 @@ +# Event-based Telemetry Success Counters (browser-side) + +Using the UI Counters API, it reports the stats coming from the `core.analytics.telemetryCounters$` observable. It allows us to track the success of the EBT client on the browser. + +## Field mappings + +As the number of fields available in the Usage API is reduced, this collection merges some fields to be able to report it. + +| UI Counter field | Telemetry Counter fields | +|------------------|--------------------------------------------------------------------------------------------------| +| `appName` | Concatenation of the string `'ebt_counters.'` and the `source` (`'client'` or the shipper name). | +| `eventName` | Matches the `eventType`. | +| `counterType` | Concatenation of the `type` and the `code` (i.e.: `'succeeded_200'`). | +| `total` | Matches the value in `count`. | \ No newline at end of file diff --git a/src/plugins/kibana_usage_collection/public/ebt_counters/index.ts b/src/plugins/kibana_usage_collection/public/ebt_counters/index.ts new file mode 100644 index 000000000000000..24deee4afb5d0bc --- /dev/null +++ b/src/plugins/kibana_usage_collection/public/ebt_counters/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 { registerEbtCounters } from './register_ebt_counters'; diff --git a/src/plugins/kibana_usage_collection/public/ebt_counters/register_ebt_counters.test.ts b/src/plugins/kibana_usage_collection/public/ebt_counters/register_ebt_counters.test.ts new file mode 100644 index 000000000000000..2bf67d02fe1104d --- /dev/null +++ b/src/plugins/kibana_usage_collection/public/ebt_counters/register_ebt_counters.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { TelemetryCounter } from '@kbn/analytics-client'; +import { TelemetryCounterType } from '@kbn/analytics-client'; +import { coreMock } from '@kbn/core/public/mocks'; +import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/public/mocks'; +import { registerEbtCounters } from './register_ebt_counters'; + +describe('registerEbtCounters', () => { + let core: ReturnType; + let usageCollection: ReturnType; + let internalListener: (counter: TelemetryCounter) => void; + let telemetryCounter$Spy: jest.SpyInstance; + + beforeEach(() => { + core = coreMock.createSetup(); + usageCollection = usageCollectionPluginMock.createSetupContract(); + telemetryCounter$Spy = jest + .spyOn(core.analytics.telemetryCounter$, 'subscribe') + .mockImplementation(((listener) => { + internalListener = listener as (counter: TelemetryCounter) => void; + }) as typeof core.analytics.telemetryCounter$['subscribe']); + }); + + test('it subscribes to `analytics.telemetryCounters$`', () => { + registerEbtCounters(core.analytics, usageCollection); + expect(telemetryCounter$Spy).toHaveBeenCalledTimes(1); + }); + + test('it reports a UI counter whenever a counter is emitted', () => { + registerEbtCounters(core.analytics, usageCollection); + expect(telemetryCounter$Spy).toHaveBeenCalledTimes(1); + internalListener({ + type: TelemetryCounterType.succeeded, + source: 'test-shipper', + event_type: 'test-event', + code: 'test-code', + count: 1, + }); + expect(usageCollection.reportUiCounter).toHaveBeenCalledTimes(1); + expect(usageCollection.reportUiCounter).toHaveBeenCalledWith( + 'ebt_counters.test-shipper', + 'succeeded_test-code', + 'test-event', + 1 + ); + }); +}); diff --git a/src/plugins/kibana_usage_collection/public/ebt_counters/register_ebt_counters.ts b/src/plugins/kibana_usage_collection/public/ebt_counters/register_ebt_counters.ts new file mode 100644 index 000000000000000..483e00d8d03fe16 --- /dev/null +++ b/src/plugins/kibana_usage_collection/public/ebt_counters/register_ebt_counters.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 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 { AnalyticsServiceSetup } from '@kbn/core/public'; +import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; + +export function registerEbtCounters( + analytics: AnalyticsServiceSetup, + usageCollection: UsageCollectionSetup +) { + // The client should complete telemetryCounter$ when shutting down. We shouldn't need to pipe(takeUntil(stop$)). + analytics.telemetryCounter$.subscribe(({ type, source, event_type: eventType, code, count }) => { + usageCollection.reportUiCounter(`ebt_counters.${source}`, `${type}_${code}`, eventType, count); + }); +} diff --git a/src/plugins/kibana_usage_collection/public/index.ts b/src/plugins/kibana_usage_collection/public/index.ts new file mode 100644 index 000000000000000..5474b8db0b27f2f --- /dev/null +++ b/src/plugins/kibana_usage_collection/public/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { KibanaUsageCollectionPlugin } from './plugin'; + +export function plugin() { + return new KibanaUsageCollectionPlugin(); +} diff --git a/src/plugins/kibana_usage_collection/public/plugin.ts b/src/plugins/kibana_usage_collection/public/plugin.ts new file mode 100644 index 000000000000000..2b7a4b868b76ae1 --- /dev/null +++ b/src/plugins/kibana_usage_collection/public/plugin.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 { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; +import type { CoreSetup, Plugin } from '@kbn/core/public'; +import { registerEbtCounters } from './ebt_counters'; + +interface KibanaUsageCollectionPluginsDepsSetup { + usageCollection: UsageCollectionSetup; +} + +export class KibanaUsageCollectionPlugin implements Plugin { + public setup(coreSetup: CoreSetup, { usageCollection }: KibanaUsageCollectionPluginsDepsSetup) { + registerEbtCounters(coreSetup.analytics, usageCollection); + } + + public start() {} + + public stop() {} +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index 7a19ff022226e67..a948a035f2d4884 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -418,6 +418,10 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'observability:enableNewSyntheticsView': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, 'observability:maxSuggestions': { type: 'integer', _meta: { description: 'Non-default value of setting.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index b9d50f888fa93ba..718f75b80a77df4 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -36,6 +36,7 @@ export interface UsageStats { 'discover:maxDocFieldsDisplayed': number; 'securitySolution:rulesTableRefresh': string; 'observability:enableInspectEsQueries': boolean; + 'observability:enableNewSyntheticsView': boolean; 'observability:maxSuggestions': number; 'observability:enableComparisonByDefault': boolean; 'observability:enableInfrastructureView': boolean; diff --git a/src/plugins/kibana_usage_collection/server/ebt_counters/README.md b/src/plugins/kibana_usage_collection/server/ebt_counters/README.md new file mode 100644 index 000000000000000..46762148a952cfc --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/ebt_counters/README.md @@ -0,0 +1,14 @@ +# Event-based Telemetry Success Counters (server-side) + +Using the Usage Counters API, it reports the stats coming from the `core.analytics.telemetryCounters$` observable. It allows us to track the success of the EBT client on the server side. + +## Field mappings + +As the number of fields available in the Usage API is reduced, this collection merges some fields to be able to report it. + +| Usage Counter field | Telemetry Counter fields | +|---------------------|--------------------------------------------------------------------------------------------------| +| `domainId` | Concatenation of the string `'ebt_counters.'` and the `source` (`'client'` or the shipper name). | +| `counterName` | Matches the `eventType`. | +| `counterType` | Concatenation of the `type` and the `code` (i.e.: `'succeeded_200'`). | +| `total` | Matches the value in `count`. | \ No newline at end of file diff --git a/src/plugins/kibana_usage_collection/server/ebt_counters/index.ts b/src/plugins/kibana_usage_collection/server/ebt_counters/index.ts new file mode 100644 index 000000000000000..24deee4afb5d0bc --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/ebt_counters/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 { registerEbtCounters } from './register_ebt_counters'; diff --git a/src/plugins/kibana_usage_collection/server/ebt_counters/register_ebt_counters.test.ts b/src/plugins/kibana_usage_collection/server/ebt_counters/register_ebt_counters.test.ts new file mode 100644 index 000000000000000..ddeb85ee1be0223 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/ebt_counters/register_ebt_counters.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 type { TelemetryCounter } from '@kbn/analytics-client'; +import { TelemetryCounterType } from '@kbn/analytics-client'; +import { coreMock } from '@kbn/core/server/mocks'; +import { createUsageCollectionSetupMock } from '@kbn/usage-collection-plugin/server/mocks'; +import { registerEbtCounters } from './register_ebt_counters'; + +describe('registerEbtCounters', () => { + let core: ReturnType; + let usageCollection: ReturnType; + let internalListener: (counter: TelemetryCounter) => void; + let telemetryCounter$Spy: jest.SpyInstance; + + beforeEach(() => { + core = coreMock.createSetup(); + usageCollection = createUsageCollectionSetupMock(); + telemetryCounter$Spy = jest + .spyOn(core.analytics.telemetryCounter$, 'subscribe') + .mockImplementation(((listener) => { + internalListener = listener as (counter: TelemetryCounter) => void; + }) as typeof core.analytics.telemetryCounter$['subscribe']); + }); + + test('it subscribes to `analytics.telemetryCounters$`', () => { + registerEbtCounters(core.analytics, usageCollection); + expect(telemetryCounter$Spy).toHaveBeenCalledTimes(1); + }); + + test('it creates a new usageCounter when it does not exist', () => { + registerEbtCounters(core.analytics, usageCollection); + expect(telemetryCounter$Spy).toHaveBeenCalledTimes(1); + internalListener({ + type: TelemetryCounterType.succeeded, + source: 'test-shipper', + event_type: 'test-event', + code: 'test-code', + count: 1, + }); + expect(usageCollection.getUsageCounterByType).toHaveBeenCalledTimes(1); + expect(usageCollection.getUsageCounterByType).toHaveBeenCalledWith('ebt_counters.test-shipper'); + expect(usageCollection.createUsageCounter).toHaveBeenCalledTimes(1); + expect(usageCollection.createUsageCounter).toHaveBeenCalledWith('ebt_counters.test-shipper'); + }); + + test('it reuses the usageCounter when it already exists', () => { + const incrementCounterMock = jest.fn(); + usageCollection.getUsageCounterByType.mockReturnValue({ + incrementCounter: incrementCounterMock, + }); + registerEbtCounters(core.analytics, usageCollection); + expect(telemetryCounter$Spy).toHaveBeenCalledTimes(1); + internalListener({ + type: TelemetryCounterType.succeeded, + source: 'test-shipper', + event_type: 'test-event', + code: 'test-code', + count: 1, + }); + expect(usageCollection.getUsageCounterByType).toHaveBeenCalledTimes(1); + expect(usageCollection.getUsageCounterByType).toHaveBeenCalledWith('ebt_counters.test-shipper'); + expect(usageCollection.createUsageCounter).toHaveBeenCalledTimes(0); + expect(incrementCounterMock).toHaveBeenCalledTimes(1); + expect(incrementCounterMock).toHaveBeenCalledWith({ + counterName: 'test-event', + counterType: `succeeded_test-code`, + incrementBy: 1, + }); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/ebt_counters/register_ebt_counters.ts b/src/plugins/kibana_usage_collection/server/ebt_counters/register_ebt_counters.ts new file mode 100644 index 000000000000000..ed2100dccf929b8 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/ebt_counters/register_ebt_counters.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { AnalyticsServiceSetup } from '@kbn/core/server'; +import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; + +export function registerEbtCounters( + analytics: AnalyticsServiceSetup, + usageCollection: UsageCollectionSetup +) { + // The client should complete telemetryCounter$ when shutting down. We shouldn't need to pipe(takeUntil(stop$)). + analytics.telemetryCounter$.subscribe(({ type, source, event_type: eventType, code, count }) => { + // We create one counter per source ('client'|). + const domainId = `ebt_counters.${source}`; + const usageCounter = + usageCollection.getUsageCounterByType(domainId) ?? + usageCollection.createUsageCounter(domainId); + + usageCounter.incrementCounter({ + counterName: eventType, // the name of the event + counterType: `${type}_${code}`, // e.g. 'succeeded_200' + incrementBy: count, + }); + }); +} diff --git a/src/plugins/kibana_usage_collection/server/mocks.ts b/src/plugins/kibana_usage_collection/server/plugin.test.mocks.ts similarity index 83% rename from src/plugins/kibana_usage_collection/server/mocks.ts rename to src/plugins/kibana_usage_collection/server/plugin.test.mocks.ts index 7df27a3719e92a4..a21b2b007f5e9e4 100644 --- a/src/plugins/kibana_usage_collection/server/mocks.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.test.mocks.ts @@ -16,3 +16,9 @@ export const detectCloudServiceMock = mock.detectCloudService; jest.doMock('./collectors/cloud/detector', () => ({ CloudDetector: jest.fn().mockImplementation(() => mock), })); + +export const registerEbtCountersMock = jest.fn(); + +jest.doMock('./ebt_counters', () => ({ + registerEbtCounters: registerEbtCountersMock, +})); diff --git a/src/plugins/kibana_usage_collection/server/plugin.test.ts b/src/plugins/kibana_usage_collection/server/plugin.test.ts index a6604ac0bc1cd6c..ef26492c2d6fd17 100644 --- a/src/plugins/kibana_usage_collection/server/plugin.test.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.test.ts @@ -15,7 +15,7 @@ import { CollectorOptions, createUsageCollectionSetupMock, } from '@kbn/usage-collection-plugin/server/mocks'; -import { cloudDetailsMock } from './mocks'; +import { cloudDetailsMock, registerEbtCountersMock } from './plugin.test.mocks'; import { plugin } from '.'; describe('kibana_usage_collection', () => { @@ -44,6 +44,9 @@ describe('kibana_usage_collection', () => { expect(coreSetup.coreUsageData.registerUsageCounter).toHaveBeenCalled(); + expect(registerEbtCountersMock).toHaveBeenCalledTimes(1); + expect(registerEbtCountersMock).toHaveBeenCalledWith(coreSetup.analytics, usageCollection); + await expect( Promise.all( usageCollectors.map(async (usageCollector) => { diff --git a/src/plugins/kibana_usage_collection/server/plugin.ts b/src/plugins/kibana_usage_collection/server/plugin.ts index 34bf02931130712..10f05ccbac945d5 100644 --- a/src/plugins/kibana_usage_collection/server/plugin.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.ts @@ -21,6 +21,7 @@ import type { CoreUsageDataStart, } from '@kbn/core/server'; import { SavedObjectsClient, EventLoopDelaysMonitor } from '@kbn/core/server'; +import { registerEbtCounters } from './ebt_counters'; import { startTrackingEventLoopDelaysUsage, startTrackingEventLoopDelaysThreshold, @@ -68,6 +69,7 @@ export class KibanaUsageCollectionPlugin implements Plugin { } public setup(coreSetup: CoreSetup, { usageCollection }: KibanaUsageCollectionPluginsDepsSetup) { + registerEbtCounters(coreSetup.analytics, usageCollection); usageCollection.createUsageCounter('uiCounters'); this.eventLoopUsageCounter = usageCollection.createUsageCounter('eventLoop'); coreSetup.coreUsageData.registerUsageCounter(usageCollection.createUsageCounter('core')); diff --git a/src/plugins/kibana_usage_collection/tsconfig.json b/src/plugins/kibana_usage_collection/tsconfig.json index e57d6e25db8cd41..d9a1e648995bbbc 100644 --- a/src/plugins/kibana_usage_collection/tsconfig.json +++ b/src/plugins/kibana_usage_collection/tsconfig.json @@ -9,6 +9,7 @@ }, "include": [ "common/*", + "public/**/**/*", "server/**/**/*", "../../../typings/*" ], diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index db6cf1bc3d00685..5ae2a4498b55bfa 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -4,6 +4,12 @@ } } +.kbnTopNavMenu__wrapper { + &--hidden { + display: none; + } +} + .kbnTopNavMenu__badgeWrapper { display: flex; align-items: baseline; diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx index 85a9803ffced67b..aee35c1f331c733 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx @@ -159,7 +159,6 @@ describe('TopNavMenu', () => { await refresh(); - expect(component.find(WRAPPER_SELECTOR).length).toBe(1); expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(1); // menu is rendered outside of the component diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index 7eb7365ed79f35f..86c83a6b48be500 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -28,6 +28,7 @@ export type TopNavMenuProps = StatefulSearchBarProps & showFilterBar?: boolean; unifiedSearch?: UnifiedSearchPublicPluginStart; className?: string; + visible?: boolean; /** * If provided, the menu part of the component will be rendered as a portal inside the given mount point. * @@ -105,9 +106,11 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { } function renderLayout() { - const { setMenuMountPoint } = props; + const { setMenuMountPoint, visible } = props; const menuClassName = classNames('kbnTopNavMenu', props.className); - const wrapperClassName = 'kbnTopNavMenu__wrapper'; + const wrapperClassName = classNames('kbnTopNavMenu__wrapper', { + 'kbnTopNavMenu__wrapper--hidden': visible === false, + }); if (setMenuMountPoint) { return ( <> @@ -117,15 +120,15 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { {renderMenu(menuClassName)} - {renderSearchBar()} + {renderSearchBar()} ); } else { return ( - - {renderMenu(menuClassName)} + <> + {renderMenu(menuClassName)} {renderSearchBar()} - + ); } } diff --git a/src/plugins/newsfeed/README.md b/src/plugins/newsfeed/README.md index d8a0bffb4ed0bbf..398578092425dd5 100644 --- a/src/plugins/newsfeed/README.md +++ b/src/plugins/newsfeed/README.md @@ -1,4 +1,4 @@ # newsfeed The newsfeed plugin adds a NewsfeedNavButton to the top navigation bar and renders the content in the flyout. -Content is fetched from the remote (https://feeds.elastic.co and https://feeds-staging.elastic.co in dev mode) once a day, with periodic checks if the content needs to be refreshed. All newsfeed content is hosted remotely. +Content is fetched from the remote (https://feeds.elastic.co) once a day, with periodic checks if the content needs to be refreshed. All newsfeed content is hosted remotely. diff --git a/src/plugins/newsfeed/common/constants.ts b/src/plugins/newsfeed/common/constants.ts index 6ba5e07ea873ebb..f4467dfe35011ff 100644 --- a/src/plugins/newsfeed/common/constants.ts +++ b/src/plugins/newsfeed/common/constants.ts @@ -8,5 +8,4 @@ export const NEWSFEED_FALLBACK_LANGUAGE = 'en'; export const NEWSFEED_DEFAULT_SERVICE_BASE_URL = 'https://feeds.elastic.co'; -export const NEWSFEED_DEV_SERVICE_BASE_URL = 'https://feeds-staging.elastic.co'; export const NEWSFEED_DEFAULT_SERVICE_PATH = '/kibana/v{VERSION}.json'; diff --git a/src/plugins/newsfeed/public/components/flyout_list.tsx b/src/plugins/newsfeed/public/components/flyout_list.tsx index 622ae287bd0c16f..8abc0896fff4f8d 100644 --- a/src/plugins/newsfeed/public/components/flyout_list.tsx +++ b/src/plugins/newsfeed/public/components/flyout_list.tsx @@ -11,6 +11,7 @@ import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, + EuiFlyoutProps, EuiTitle, EuiLink, EuiFlyoutFooter, @@ -28,13 +29,14 @@ import { NewsfeedItem } from '../types'; import { NewsEmptyPrompt } from './empty_news'; import { NewsLoadingPrompt } from './loading_news'; -export const NewsfeedFlyout = () => { +export const NewsfeedFlyout = (props: Partial) => { const { newsFetchResult, setFlyoutVisible } = useContext(NewsfeedContext); const closeFlyout = useCallback(() => setFlyoutVisible(false), [setFlyoutVisible]); return ( { return newsFetchResult ? newsFetchResult.hasNew : false; }, [newsFetchResult]); + const buttonRef = useRef(null); + const setButtonRef = (node: HTMLButtonElement | null) => (buttonRef.current = node); + useEffect(() => { const subscription = newsfeedApi.fetchResults$.subscribe((results) => { setNewsFetchResult(results); @@ -49,6 +52,7 @@ export const NewsfeedNavButton = ({ newsfeedApi }: Props) => { <> { > - {flyoutVisible ? : null} + {flyoutVisible ? : null} ); diff --git a/src/plugins/newsfeed/server/config.ts b/src/plugins/newsfeed/server/config.ts index f14f3452761e114..f371da244f871b9 100644 --- a/src/plugins/newsfeed/server/config.ts +++ b/src/plugins/newsfeed/server/config.ts @@ -10,19 +10,13 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { NEWSFEED_DEFAULT_SERVICE_PATH, NEWSFEED_DEFAULT_SERVICE_BASE_URL, - NEWSFEED_DEV_SERVICE_BASE_URL, } from '../common/constants'; export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), service: schema.object({ pathTemplate: schema.string({ defaultValue: NEWSFEED_DEFAULT_SERVICE_PATH }), - urlRoot: schema.conditional( - schema.contextRef('prod'), - schema.literal(true), // Point to staging if it's not a production release - schema.string({ defaultValue: NEWSFEED_DEFAULT_SERVICE_BASE_URL }), - schema.string({ defaultValue: NEWSFEED_DEV_SERVICE_BASE_URL }) - ), + urlRoot: schema.string({ defaultValue: NEWSFEED_DEFAULT_SERVICE_BASE_URL }), }), mainInterval: schema.duration({ defaultValue: '2m' }), // (2min) How often to retry failed fetches, and/or check if newsfeed items need to be refreshed from remote fetchInterval: schema.duration({ defaultValue: '1d' }), // (1day) How often to fetch remote and reset the last fetched time diff --git a/src/plugins/presentation_util/public/components/field_picker/field_picker.tsx b/src/plugins/presentation_util/public/components/field_picker/field_picker.tsx index b47e425f67bb2c6..3c8a084f2686ba8 100644 --- a/src/plugins/presentation_util/public/components/field_picker/field_picker.tsx +++ b/src/plugins/presentation_util/public/components/field_picker/field_picker.tsx @@ -40,7 +40,7 @@ export const FieldPicker = ({ dataView.fields .filter( (f) => - f.name.includes(nameFilter) && + f.name.toLowerCase().includes(nameFilter.toLowerCase()) && (typesFilter.length === 0 || typesFilter.includes(f.type as string)) ) .filter((f) => (filterPredicate ? filterPredicate(f) : true)), diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx index 7b7ac93c14a97b4..dc98b098f84282a 100644 --- a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx +++ b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx @@ -94,6 +94,7 @@ export const ReduxEmbeddableWrapper = = ReduxEmbeddableContextServices & { containerActions: Pick< IContainer, - 'untilEmbeddableLoaded' | 'removeEmbeddable' | 'addNewEmbeddable' | 'updateInputForChild' + | 'untilEmbeddableLoaded' + | 'removeEmbeddable' + | 'addNewEmbeddable' + | 'updateInputForChild' + | 'replaceEmbeddable' >; }; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.test.tsx index bb949f855ff18d6..14c8c6e0c47c35e 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_summary.test.tsx @@ -13,9 +13,6 @@ import { httpServiceMock } from '@kbn/core/public/mocks'; import { ImportSummary, ImportSummaryProps } from './import_summary'; import { FailedImport } from '../../../lib'; -// @ts-expect-error -import { findTestSubject } from '@elastic/eui/lib/test'; - describe('ImportSummary', () => { let basePath: ReturnType; diff --git a/src/plugins/telemetry/public/plugin.test.ts b/src/plugins/telemetry/public/plugin.test.ts index f25bf92340ba60e..13ebb4d999315eb 100644 --- a/src/plugins/telemetry/public/plugin.test.ts +++ b/src/plugins/telemetry/public/plugin.test.ts @@ -66,7 +66,19 @@ describe('TelemetryPlugin', () => { expect(coreSetupMock.analytics.registerShipper).toHaveBeenCalledWith( ElasticV3BrowserShipper, - { channelName: 'kibana-browser', version: 'version' } + { channelName: 'kibana-browser', version: 'version', sendTo: 'staging' } + ); + }); + + it('registers the UI telemetry shipper (pointing to prod)', () => { + const initializerContext = coreMock.createPluginInitializerContext({ sendUsageTo: 'prod' }); + const coreSetupMock = coreMock.createSetup(); + + new TelemetryPlugin(initializerContext).setup(coreSetupMock, { screenshotMode, home }); + + expect(coreSetupMock.analytics.registerShipper).toHaveBeenCalledWith( + ElasticV3BrowserShipper, + { channelName: 'kibana-browser', version: 'version', sendTo: 'production' } ); }); }); diff --git a/src/plugins/telemetry/public/plugin.ts b/src/plugins/telemetry/public/plugin.ts index 6fe7fe4138a317b..dea9cd1df2c3cc1 100644 --- a/src/plugins/telemetry/public/plugin.ts +++ b/src/plugins/telemetry/public/plugin.ts @@ -158,6 +158,7 @@ export class TelemetryPlugin implements Plugin { diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index 59d7ba693156d89..adabf8ea3d854ba 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -202,29 +202,29 @@ } } }, - "search": { + "search-session": { "properties": { - "successCount": { + "transientCount": { "type": "long" }, - "errorCount": { + "persistedCount": { "type": "long" }, - "averageDuration": { - "type": "float" + "totalCount": { + "type": "long" } } }, - "search-session": { + "search": { "properties": { - "transientCount": { + "successCount": { "type": "long" }, - "persistedCount": { + "errorCount": { "type": "long" }, - "totalCount": { - "type": "long" + "averageDuration": { + "type": "float" } } }, @@ -8133,6 +8133,12 @@ "description": "Non-default value of setting." } }, + "observability:enableNewSyntheticsView": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "observability:maxSuggestions": { "type": "integer", "_meta": { @@ -9604,6 +9610,142 @@ } } }, + "usage_collector_stats": { + "properties": { + "total_duration": { + "type": "long", + "_meta": { + "description": "The total execution duration to grab usage stats for all collectors in milliseconds" + } + }, + "total_is_ready_duration": { + "type": "long", + "_meta": { + "description": "The total execution duration of the isReady function for all collectors in milliseconds" + } + }, + "total_fetch_duration": { + "type": "long", + "_meta": { + "description": "The total execution duration of the fetch function for all ready collectors in milliseconds" + } + }, + "is_ready_duration_breakdown": { + "type": "array", + "items": { + "properties": { + "name": { + "type": "keyword", + "_meta": { + "description": "The name of the collector" + } + }, + "duration": { + "type": "long", + "_meta": { + "description": "The execution duration of the isReady function for the collector in milliseconds" + } + } + } + } + }, + "fetch_duration_breakdown": { + "type": "array", + "items": { + "properties": { + "name": { + "type": "keyword", + "_meta": { + "description": "The name of the collector" + } + }, + "duration": { + "type": "long", + "_meta": { + "description": "The execution duration of the fetch function for the collector in milliseconds" + } + } + } + } + }, + "not_ready": { + "properties": { + "count": { + "type": "short", + "_meta": { + "description": "The number of collectors that returned false from the isReady function" + } + }, + "names": { + "type": "array", + "items": { + "type": "keyword", + "_meta": { + "description": "The name of the of collectors that returned false from the isReady function" + } + } + } + } + }, + "not_ready_timeout": { + "properties": { + "count": { + "type": "short", + "_meta": { + "description": "The number of collectors that timedout during the isReady function" + } + }, + "names": { + "type": "array", + "items": { + "type": "keyword", + "_meta": { + "description": "The name of collectors that timedout during the isReady function" + } + } + } + } + }, + "succeeded": { + "properties": { + "count": { + "type": "short", + "_meta": { + "description": "The number of collectors that returned true from the fetch function" + } + }, + "names": { + "type": "array", + "items": { + "type": "keyword", + "_meta": { + "description": "The name of the of collectors that returned true from the fetch function" + } + } + } + } + }, + "failed": { + "properties": { + "count": { + "type": "short", + "_meta": { + "description": "The number of collectors that threw an error from the fetch function" + } + }, + "names": { + "type": "array", + "items": { + "type": "keyword", + "_meta": { + "description": "The name of the of collectors that threw an error from the fetch function" + } + } + } + } + } + } + }, "vis_type_table": { "properties": { "total": { diff --git a/src/plugins/telemetry/schema/oss_root.json b/src/plugins/telemetry/schema/oss_root.json index cf9b881facef24d..e526dc6413916b0 100644 --- a/src/plugins/telemetry/schema/oss_root.json +++ b/src/plugins/telemetry/schema/oss_root.json @@ -194,62 +194,6 @@ "properties": { "kibana_config_usage": { "type": "pass_through" - }, - "usage_collector_stats": { - "properties": { - "not_ready": { - "properties": { - "count": { - "type": "short" - }, - "names": { - "type": "array", - "items": { - "type": "keyword" - } - } - } - }, - "not_ready_timeout": { - "properties": { - "count": { - "type": "short" - }, - "names": { - "type": "array", - "items": { - "type": "keyword" - } - } - } - }, - "succeeded": { - "properties": { - "count": { - "type": "short" - }, - "names": { - "type": "array", - "items": { - "type": "keyword" - } - } - } - }, - "failed": { - "properties": { - "count": { - "type": "short" - }, - "names": { - "type": "array", - "items": { - "type": "keyword" - } - } - } - } - } } } } diff --git a/src/plugins/telemetry/server/plugin.test.ts b/src/plugins/telemetry/server/plugin.test.ts index c31e78722abf593..9f7d0cfcf812fd9 100644 --- a/src/plugins/telemetry/server/plugin.test.ts +++ b/src/plugins/telemetry/server/plugin.test.ts @@ -26,7 +26,22 @@ describe('TelemetryPlugin', () => { expect(coreSetupMock.analytics.registerShipper).toHaveBeenCalledWith( ElasticV3ServerShipper, - { channelName: 'kibana-server', version: 'version' } + { channelName: 'kibana-server', version: 'version', sendTo: 'staging' } + ); + }); + + it('registers the Server telemetry shipper (sendTo: production)', () => { + const initializerContext = coreMock.createPluginInitializerContext({ sendUsageTo: 'prod' }); + const coreSetupMock = coreMock.createSetup(); + + new TelemetryPlugin(initializerContext).setup(coreSetupMock, { + usageCollection: usageCollectionPluginMock.createSetupContract(), + telemetryCollectionManager: telemetryCollectionManagerPluginMock.createSetupContract(), + }); + + expect(coreSetupMock.analytics.registerShipper).toHaveBeenCalledWith( + ElasticV3ServerShipper, + { channelName: 'kibana-server', version: 'version', sendTo: 'production' } ); }); }); diff --git a/src/plugins/telemetry/server/plugin.ts b/src/plugins/telemetry/server/plugin.ts index c3c2604dc9a0314..881407fb8e28872 100644 --- a/src/plugins/telemetry/server/plugin.ts +++ b/src/plugins/telemetry/server/plugin.ts @@ -85,6 +85,7 @@ type SavedObjectsRegisterType = CoreSetup['savedObjects']['registerType']; export class TelemetryPlugin implements Plugin { private readonly logger: Logger; private readonly currentKibanaVersion: string; + private readonly initialConfig: TelemetryConfigType; private readonly config$: Observable; private readonly isOptedIn$ = new BehaviorSubject(undefined); private readonly isDev: boolean; @@ -122,13 +123,14 @@ export class TelemetryPlugin implements Plugin { + return []; + }, + add: action('set'), + get$: () => { + return { + pipe: () => {}, + }; + }, +}; + +const createMockWebStorage = () => ({ + clear: action('clear'), + getItem: action('getItem'), + key: action('key'), + removeItem: action('removeItem'), + setItem: action('setItem'), + length: 0, +}); + +const createMockStorage = () => ({ + storage: createMockWebStorage(), + set: action('set'), + remove: action('remove'), + clear: action('clear'), + get: () => true, +}); + +const services = { + uiSettings: { + get: () => {}, + }, + savedObjects: action('savedObjects'), + notifications: action('notifications'), + http: { + basePath: { + prepend: () => 'http://test', + }, + }, + docLinks: { + links: { + query: { + kueryQuerySyntax: '', + }, + }, + }, + storage: createMockStorage(), + data: { + query: { + savedQueries: { + findSavedQueries: () => + Promise.resolve({ + queries: [ + { + id: 'testwewe', + attributes: { + title: 'Saved query 1', + description: '', + query: { + query: 'category.keyword : "Men\'s Shoes" ', + language: 'kuery', + }, + filters: [], + }, + }, + { + id: '0173d0d0-b19a-11ec-8323-837d6b231b82', + attributes: { + title: 'test', + description: '', + query: { + query: '', + language: 'kuery', + }, + filters: [ + { + meta: { + index: '1234', + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'category.keyword', + params: { + query: "Men's Accessories", + }, + }, + query: { + match_phrase: { + 'category.keyword': "Men's Accessories", + }, + }, + $state: { + store: 'appState', + }, + }, + ], + }, + }, + ], + }), + }, + }, + autocomplete: { + hasQuerySuggestions: () => Promise.resolve(false), + getQuerySuggestions: () => [], + }, + dataViews: { + getIdsWithTitle: () => [ + { id: '1234', title: 'logstash-*' }, + { id: '1235', title: 'test-*' }, + ], + }, + }, +}; + +setIndexPatterns({ + get: () => Promise.resolve(mockIndexPatterns[0]), +} as unknown as DataViewsContract); + +function wrapSearchBarInContext(testProps: SearchBarProps) { + const defaultOptions = { + appName: 'test', + timeHistory: mockTimeHistory, + intl: null as any, + showQueryBar: true, + showFilterBar: true, + showDatePicker: true, + showAutoRefreshOnly: false, + showSaveQuery: true, + showQueryInput: true, + indexPatterns: mockIndexPatterns, + dateRangeFrom: 'now-15m', + dateRangeTo: 'now', + query: { query: '', language: 'kuery' }, + filters: [], + onClearSavedQuery: action('onClearSavedQuery'), + onFiltersUpdated: action('onFiltersUpdated'), + } as unknown as SearchBarProps; + + return ( + + + + + + ); +} + +storiesOf('SearchBar', module) + .add('default', () => wrapSearchBarInContext({ showQueryInput: true } as SearchBarProps)) + .add('with dataviewPicker', () => + wrapSearchBarInContext({ + dataViewPickerComponentProps: { + currentDataViewId: '1234', + trigger: { + 'data-test-subj': 'dataView-switch-link', + label: 'logstash-*', + title: 'logstash-*', + }, + onChangeDataView: action('onChangeDataView'), + }, + } as SearchBarProps) + ) + .add('with dataviewPicker enhanced', () => + wrapSearchBarInContext({ + dataViewPickerComponentProps: { + currentDataViewId: '1234', + trigger: { + 'data-test-subj': 'dataView-switch-link', + label: 'logstash-*', + title: 'logstash-*', + }, + onChangeDataView: action('onChangeDataView'), + onAddField: action('onAddField'), + onDataViewCreated: action('onDataViewCreated'), + }, + } as SearchBarProps) + ) + .add('with filterBar off', () => + wrapSearchBarInContext({ + showFilterBar: false, + } as SearchBarProps) + ) + .add('with query input off', () => + wrapSearchBarInContext({ + showQueryInput: false, + } as SearchBarProps) + ) + .add('with date picker off', () => + wrapSearchBarInContext({ + showDatePicker: false, + } as SearchBarProps) + ) + .add('with date picker off', () => + wrapSearchBarInContext({ + showDatePicker: false, + } as SearchBarProps) + ) + .add('with only the date picker on', () => + wrapSearchBarInContext({ + showDatePicker: true, + showFilterBar: false, + showQueryInput: false, + } as SearchBarProps) + ) + .add('with only the filter bar on', () => + wrapSearchBarInContext({ + showDatePicker: false, + showFilterBar: true, + showQueryInput: false, + filters: [ + { + meta: { + index: '1234', + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'category.keyword', + params: { + query: "Men's Accessories", + }, + }, + query: { + match_phrase: { + 'category.keyword': "Men's Accessories", + }, + }, + $state: { + store: 'appState', + }, + }, + ], + } as unknown as SearchBarProps) + ) + .add('with only the query bar on', () => + wrapSearchBarInContext({ + showDatePicker: false, + showFilterBar: false, + showQueryInput: true, + query: { query: 'Test: miaou', language: 'kuery' }, + } as unknown as SearchBarProps) + ) + .add('with only the filter bar and the date picker on', () => + wrapSearchBarInContext({ + showDatePicker: true, + showFilterBar: true, + showQueryInput: false, + filters: [ + { + meta: { + index: '1234', + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'category.keyword', + params: { + query: "Men's Accessories", + }, + }, + query: { + match_phrase: { + 'category.keyword': "Men's Accessories", + }, + }, + $state: { + store: 'appState', + }, + }, + ], + } as unknown as SearchBarProps) + ) + .add('with loaded saved query without changes', () => + wrapSearchBarInContext({ + savedQuery: { + id: '0173d0d0-b19a-11ec-8323-837d6b231b82', + attributes: { + title: 'test', + description: '', + query: { + query: '', + language: 'kuery', + }, + filters: [ + { + meta: { + index: '1234', + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'category.keyword', + params: { + query: "Men's Accessories", + }, + }, + query: { + match_phrase: { + 'category.keyword': "Men's Accessories", + }, + }, + $state: { + store: 'appState', + }, + }, + ], + }, + }, + filters: [ + { + meta: { + index: '1234', + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'category.keyword', + params: { + query: "Men's Accessories", + }, + }, + query: { + match_phrase: { + 'category.keyword': "Men's Accessories", + }, + }, + $state: { + store: 'appState', + }, + }, + ], + } as unknown as SearchBarProps) + ) + .add('with loaded saved query with changes', () => + wrapSearchBarInContext({ + savedQuery: { + id: '0173d0d0-b19a-11ec-8323-837d6b231b82', + attributes: { + title: 'test', + description: '', + query: { + query: '', + language: 'kuery', + }, + filters: [ + { + meta: { + index: '1234', + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'category.keyword', + params: { + query: "Men's Accessories", + }, + }, + query: { + match_phrase: { + 'category.keyword': "Men's Accessories", + }, + }, + $state: { + store: 'appState', + }, + }, + ], + }, + }, + } as unknown as SearchBarProps) + ) + .add('show only query bar without submit', () => + wrapSearchBarInContext({ + showDatePicker: false, + showFilterBar: false, + showAutoRefreshOnly: false, + showQueryInput: true, + showSubmitButton: false, + } as SearchBarProps) + ) + .add('with filter bar on but pinning option is hidden from menus', () => + wrapSearchBarInContext({ + showDatePicker: false, + showFilterBar: true, + showQueryInput: true, + hiddenFilterPanelOptions: ['pinFilter'], + filters: [ + { + meta: { + index: '1234', + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'category.keyword', + params: { + query: "Men's Accessories", + }, + }, + query: { + match_phrase: { + 'category.keyword': "Men's Accessories", + }, + }, + $state: { + store: 'appState', + }, + }, + ], + } as unknown as SearchBarProps) + ); diff --git a/src/plugins/unified_search/public/dataview_picker/change_dataview.styles.ts b/src/plugins/unified_search/public/dataview_picker/change_dataview.styles.ts new file mode 100644 index 000000000000000..1c505752d392c66 --- /dev/null +++ b/src/plugins/unified_search/public/dataview_picker/change_dataview.styles.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 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 const DATA_VIEW_POPOVER_CONTENT_WIDTH = 280; + +export const changeDataViewStyles = ({ fullWidth }: { fullWidth?: boolean }) => { + return { + trigger: { + maxWidth: fullWidth ? undefined : DATA_VIEW_POPOVER_CONTENT_WIDTH, + }, + popoverContent: { + width: DATA_VIEW_POPOVER_CONTENT_WIDTH, + }, + }; +}; diff --git a/src/plugins/unified_search/public/dataview_picker/change_dataview.test.tsx b/src/plugins/unified_search/public/dataview_picker/change_dataview.test.tsx new file mode 100644 index 000000000000000..d3081561a0c4e29 --- /dev/null +++ b/src/plugins/unified_search/public/dataview_picker/change_dataview.test.tsx @@ -0,0 +1,138 @@ +/* + * 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 React from 'react'; +import { I18nProvider } from '@kbn/i18n-react'; +import { act } from 'react-dom/test-utils'; +import { mountWithIntl as mount } from '@kbn/test-jest-helpers'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { ChangeDataView } from './change_dataview'; +import { EuiTourStep } from '@elastic/eui'; +import type { DataViewPickerProps } from '.'; + +describe('DataView component', () => { + const createMockWebStorage = () => ({ + clear: jest.fn(), + getItem: jest.fn(), + key: jest.fn(), + removeItem: jest.fn(), + setItem: jest.fn(), + length: 0, + }); + + const createMockStorage = () => ({ + storage: createMockWebStorage(), + get: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + clear: jest.fn(), + }); + const getStorage = (v: boolean) => { + const storage = createMockStorage(); + storage.get.mockReturnValue(v); + return storage; + }; + + function wrapDataViewComponentInContext(testProps: DataViewPickerProps, storageValue: boolean) { + let dataMock = dataPluginMock.createStartContract(); + dataMock = { + ...dataMock, + dataViews: { + ...dataMock.dataViews, + getIdsWithTitle: jest.fn(), + }, + }; + const services = { + data: dataMock, + storage: getStorage(storageValue), + }; + + return ( + + + + + + ); + } + let props: DataViewPickerProps; + beforeEach(() => { + props = { + currentDataViewId: 'dataview-1', + trigger: { + label: 'Dataview 1', + title: 'Dataview 1', + fullWidth: true, + 'data-test-subj': 'dataview-trigger', + }, + onChangeDataView: jest.fn(), + }; + }); + it('should not render the tour component by default', async () => { + await act(async () => { + const component = mount(wrapDataViewComponentInContext(props, true)); + expect(component.find(EuiTourStep).prop('isStepOpen')).toBe(false); + }); + }); + it('should render the tour component if the showNewMenuTour is true', async () => { + const component = mount( + wrapDataViewComponentInContext({ ...props, showNewMenuTour: true }, false) + ); + expect(component.find(EuiTourStep).prop('isStepOpen')).toBe(true); + }); + + it('should not render the add runtime field menu if addField is not given', async () => { + await act(async () => { + const component = mount(wrapDataViewComponentInContext(props, true)); + findTestSubject(component, 'dataview-trigger').simulate('click'); + expect(component.find('[data-test-subj="indexPattern-add-field"]').length).toBe(0); + }); + }); + + it('should render the add runtime field menu if addField is given', async () => { + const addFieldSpy = jest.fn(); + const component = mount( + wrapDataViewComponentInContext( + { ...props, onAddField: addFieldSpy, showNewMenuTour: true }, + false + ) + ); + findTestSubject(component, 'dataview-trigger').simulate('click'); + expect(component.find('[data-test-subj="indexPattern-add-field"]').at(0).text()).toContain( + 'Add a field to this data view' + ); + component.find('[data-test-subj="indexPattern-add-field"]').first().simulate('click'); + expect(addFieldSpy).toHaveBeenCalled(); + }); + + it('should not render the add datavuew menu if onDataViewCreated is not given', async () => { + await act(async () => { + const component = mount(wrapDataViewComponentInContext(props, true)); + findTestSubject(component, 'dataview-trigger').simulate('click'); + expect(component.find('[data-test-subj="idataview-create-new"]').length).toBe(0); + }); + }); + + it('should render the add datavuew menu if onDataViewCreated is given', async () => { + const addDataViewSpy = jest.fn(); + const component = mount( + wrapDataViewComponentInContext( + { ...props, onDataViewCreated: addDataViewSpy, showNewMenuTour: true }, + false + ) + ); + findTestSubject(component, 'dataview-trigger').simulate('click'); + expect(component.find('[data-test-subj="dataview-create-new"]').at(0).text()).toContain( + 'Create a data view' + ); + component.find('[data-test-subj="dataview-create-new"]').first().simulate('click'); + expect(addDataViewSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/unified_search/public/dataview_picker/change_dataview.tsx b/src/plugins/unified_search/public/dataview_picker/change_dataview.tsx new file mode 100644 index 000000000000000..3e0ed7cc8a26641 --- /dev/null +++ b/src/plugins/unified_search/public/dataview_picker/change_dataview.tsx @@ -0,0 +1,244 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import React, { useState, useEffect } from 'react'; +import { css } from '@emotion/react'; +import { + EuiPopover, + EuiHorizontalRule, + EuiButton, + EuiContextMenuPanel, + EuiContextMenuItem, + useEuiTheme, + useGeneratedHtmlId, + EuiIcon, + EuiLink, + EuiText, + EuiTourStep, + EuiContextMenuPanelProps, +} from '@elastic/eui'; +import type { DataViewListItem } from '@kbn/data-views-plugin/public'; +import { IDataPluginServices } from '@kbn/data-plugin/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { DataViewPickerProps } from '.'; +import { DataViewsList } from './dataview_list'; +import { changeDataViewStyles } from './change_dataview.styles'; + +const NEW_DATA_VIEW_MENU_STORAGE_KEY = 'data.newDataViewMenu'; + +const newMenuTourTitle = i18n.translate('unifiedSearch.query.dataViewMenu.newMenuTour.title', { + defaultMessage: 'A better data view menu', +}); + +const newMenuTourDescription = i18n.translate( + 'unifiedSearch.query.dataViewMenu.newMenuTour.description', + { + defaultMessage: + 'This menu now offers all the tools you need to create, find, and edit your data views.', + } +); + +const newMenuTourDismissLabel = i18n.translate( + 'unifiedSearch.query.dataViewMenu.newMenuTour.dismissLabel', + { + defaultMessage: 'Got it', + } +); + +export function ChangeDataView({ + isMissingCurrent, + currentDataViewId, + onChangeDataView, + onAddField, + onDataViewCreated, + trigger, + selectableProps, + showNewMenuTour = false, +}: DataViewPickerProps) { + const { euiTheme } = useEuiTheme(); + const [isPopoverOpen, setPopoverIsOpen] = useState(false); + const [dataViewsList, setDataViewsList] = useState([]); + const [triggerLabel, setTriggerLabel] = useState(''); + const kibana = useKibana(); + const { application, data, storage } = kibana.services; + const styles = changeDataViewStyles({ fullWidth: trigger.fullWidth }); + + const [isTourDismissed, setIsTourDismissed] = useState(() => + Boolean(storage.get(NEW_DATA_VIEW_MENU_STORAGE_KEY)) + ); + const [isTourOpen, setIsTourOpen] = useState(false); + + useEffect(() => { + if (showNewMenuTour && !isTourDismissed) { + setIsTourOpen(true); + } + }, [isTourDismissed, setIsTourOpen, showNewMenuTour]); + + const onTourDismiss = () => { + storage.set(NEW_DATA_VIEW_MENU_STORAGE_KEY, true); + setIsTourDismissed(true); + setIsTourOpen(false); + }; + + // Create a reusable id to ensure search input is the first focused item in the popover even though it's not the first item + const searchListInputId = useGeneratedHtmlId({ prefix: 'dataviewPickerListSearchInput' }); + + useEffect(() => { + const fetchDataViews = async () => { + const dataViewsRefs = await data.dataViews.getIdsWithTitle(); + setDataViewsList(dataViewsRefs); + }; + fetchDataViews(); + }, [data, currentDataViewId]); + + useEffect(() => { + if (trigger.label) { + setTriggerLabel(trigger.label); + } + }, [trigger.label]); + + const createTrigger = function () { + const { label, title, 'data-test-subj': dataTestSubj, fullWidth, ...rest } = trigger; + return ( + { + setPopoverIsOpen(!isPopoverOpen); + setIsTourOpen(false); + // onTourDismiss(); TODO: Decide if opening the menu should also dismiss the tour + }} + color={isMissingCurrent ? 'danger' : 'primary'} + iconSide="right" + iconType="arrowDown" + title={title} + fullWidth={fullWidth} + {...rest} + > + {triggerLabel} + + ); + }; + + const getPanelItems = () => { + const panelItems: EuiContextMenuPanelProps['items'] = []; + if (onAddField) { + panelItems.push( + { + setPopoverIsOpen(false); + onAddField(); + }} + > + {i18n.translate('unifiedSearch.query.queryBar.indexPattern.addFieldButton', { + defaultMessage: 'Add a field to this data view', + })} + , + { + setPopoverIsOpen(false); + application.navigateToApp('management', { + path: `/kibana/indexPatterns/patterns/${currentDataViewId}`, + }); + }} + > + {i18n.translate('unifiedSearch.query.queryBar.indexPattern.manageFieldButton', { + defaultMessage: 'Manage this data view', + })} + , + + ); + } + panelItems.push( + { + onChangeDataView(newId); + setPopoverIsOpen(false); + }} + currentDataViewId={currentDataViewId} + selectableProps={selectableProps} + searchListInputId={searchListInputId} + /> + ); + + if (onDataViewCreated) { + panelItems.push( + , + { + setPopoverIsOpen(false); + onDataViewCreated(); + }} + > + {i18n.translate('unifiedSearch.query.queryBar.indexPattern.addNewDataView', { + defaultMessage: 'Create a data view', + })} + + ); + } + + return panelItems; + }; + + return ( + +   {newMenuTourTitle} + + } + content={ + +

    {newMenuTourDescription}

    +
    + } + isStepOpen={isTourOpen} + onFinish={onTourDismiss} + step={1} + stepsTotal={1} + footerAction={ + + {newMenuTourDismissLabel} + + } + repositionOnScroll + display="block" + > + setPopoverIsOpen(false)} + panelPaddingSize="none" + initialFocus={`#${searchListInputId}`} + display="block" + buffer={8} + > +
    + +
    +
    +
    + ); +} diff --git a/src/plugins/unified_search/public/dataview_picker/dataview_list.test.tsx b/src/plugins/unified_search/public/dataview_picker/dataview_list.test.tsx new file mode 100644 index 000000000000000..813beae20369c2e --- /dev/null +++ b/src/plugins/unified_search/public/dataview_picker/dataview_list.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 React from 'react'; +import { EuiSelectable } from '@elastic/eui'; +import { act } from 'react-dom/test-utils'; +import { ShallowWrapper } from 'enzyme'; +import { shallowWithIntl as shallow } from '@kbn/test-jest-helpers'; +import { DataViewsList, DataViewsListProps } from './dataview_list'; + +function getDataViewPickerList(instance: ShallowWrapper) { + return instance.find(EuiSelectable).first(); +} + +function getDataViewPickerOptions(instance: ShallowWrapper) { + return getDataViewPickerList(instance).prop('options'); +} + +function selectDataViewPickerOption(instance: ShallowWrapper, selectedLabel: string) { + const options: Array<{ label: string; checked?: 'on' | 'off' }> = getDataViewPickerOptions( + instance + ).map((option: { label: string }) => + option.label === selectedLabel + ? { ...option, checked: 'on' } + : { ...option, checked: undefined } + ); + return getDataViewPickerList(instance).prop('onChange')!(options); +} + +describe('DataView list component', () => { + const list = [ + { + id: 'dataview-1', + title: 'dataview-1', + }, + { + id: 'dataview-2', + title: 'dataview-2', + }, + ]; + const changeDataViewSpy = jest.fn(); + let props: DataViewsListProps; + beforeEach(() => { + props = { + currentDataViewId: 'dataview-1', + onChangeDataView: changeDataViewSpy, + dataViewsList: list, + }; + }); + it('should trigger the onChangeDataView if a new dataview is selected', async () => { + const component = shallow(); + await act(async () => { + selectDataViewPickerOption(component, 'dataview-2'); + }); + expect(changeDataViewSpy).toHaveBeenCalled(); + }); + + it('should list all dataviiew', () => { + const component = shallow(); + + expect(getDataViewPickerOptions(component)!.map((option: any) => option.label)).toEqual([ + 'dataview-1', + 'dataview-2', + ]); + }); +}); diff --git a/src/plugins/unified_search/public/dataview_picker/dataview_list.tsx b/src/plugins/unified_search/public/dataview_picker/dataview_list.tsx new file mode 100644 index 000000000000000..153cbdd3cf3f245 --- /dev/null +++ b/src/plugins/unified_search/public/dataview_picker/dataview_list.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 React from 'react'; +import { EuiSelectable, EuiSelectableProps, EuiPanel } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; +import { DataViewListItem } from '@kbn/data-views-plugin/public'; + +export interface DataViewsListProps { + dataViewsList: DataViewListItem[]; + onChangeDataView: (newId: string) => void; + currentDataViewId?: string; + selectableProps?: EuiSelectableProps; + searchListInputId?: string; +} + +export function DataViewsList({ + dataViewsList, + onChangeDataView, + currentDataViewId, + selectableProps, + searchListInputId, +}: DataViewsListProps) { + return ( + + {...selectableProps} + data-test-subj="indexPattern-switcher" + searchable + singleSelection="always" + options={dataViewsList?.map(({ title, id }) => ({ + key: id, + label: title, + value: id, + checked: id === currentDataViewId ? 'on' : undefined, + }))} + onChange={(choices) => { + const choice = choices.find(({ checked }) => checked) as unknown as { + value: string; + }; + onChangeDataView(choice.value); + }} + searchProps={{ + id: searchListInputId, + compressed: true, + placeholder: i18n.translate('unifiedSearch.query.queryBar.indexPattern.findDataView', { + defaultMessage: 'Find a data view', + }), + ...(selectableProps ? selectableProps.searchProps : undefined), + }} + > + {(list, search) => ( + + {search} + {list} + + )} + + ); +} diff --git a/src/plugins/unified_search/public/dataview_picker/index.tsx b/src/plugins/unified_search/public/dataview_picker/index.tsx new file mode 100644 index 000000000000000..bd24aef0498ef66 --- /dev/null +++ b/src/plugins/unified_search/public/dataview_picker/index.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 React from 'react'; +import type { EuiButtonProps, EuiSelectableProps } from '@elastic/eui'; +import { ChangeDataView } from './change_dataview'; + +export type ChangeDataViewTriggerProps = EuiButtonProps & { + label: string; + title?: string; +}; + +/** @public */ +export interface DataViewPickerProps { + trigger: ChangeDataViewTriggerProps; + isMissingCurrent?: boolean; + onChangeDataView: (newId: string) => void; + currentDataViewId?: string; + selectableProps?: EuiSelectableProps; + onAddField?: () => void; + onDataViewCreated?: () => void; + showNewMenuTour?: boolean; +} + +export const DataViewPicker = ({ + isMissingCurrent, + currentDataViewId, + onChangeDataView, + onAddField, + onDataViewCreated, + trigger, + selectableProps, + showNewMenuTour, +}: DataViewPickerProps) => { + return ( + + ); +}; diff --git a/src/plugins/unified_search/public/filter_bar/_global_filter_group.scss b/src/plugins/unified_search/public/filter_bar/_global_filter_group.scss deleted file mode 100644 index 24f3ca05a5685fd..000000000000000 --- a/src/plugins/unified_search/public/filter_bar/_global_filter_group.scss +++ /dev/null @@ -1,54 +0,0 @@ -// SASSTODO: Probably not the right file for this selector, but temporary until the files get re-organized -.globalQueryBar { - padding: 0 $euiSizeS $euiSizeS $euiSizeS; -} - -.globalQueryBar:first-child { - padding-top: $euiSizeS; -} - -.globalQueryBar:not(:empty) { - padding-bottom: $euiSizeS; -} - -.globalQueryBar--inPage { - padding: 0; -} - -.globalFilterGroup__filterBar { - margin-top: $euiSizeXS; -} - -.globalFilterBar__addButton { - min-height: $euiSizeL + $euiSizeXS; // same height as the badges -} - -// sass-lint:disable quotes -.globalFilterGroup__branch { - padding: $euiSizeS $euiSizeM 0 0; - background-repeat: no-repeat; - background-position: $euiSizeM ($euiSizeS * -1); - background-image: url("data:image/svg+xml,%0A%3Csvg width='28px' height='28px' viewBox='0 0 28 28' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='#{hexToRGB($euiColorLightShade)}'%3E%3Crect x='14' y='27' width='14' height='1'%3E%3C/rect%3E%3C/g%3E%3C/svg%3E"); -} - -.globalFilterGroup__wrapper { - line-height: 1; // Override kuiLocalNav & kuiLocalNavRow - overflow: hidden; - transition: height $euiAnimSpeedNormal $euiAnimSlightResistance; -} - -.globalFilterGroup__filterFlexItem { - overflow: hidden; - padding-bottom: 2px; // Allow the shadows of the pills to show -} - -.globalFilterBar__flexItem { - max-width: calc(100% - #{$euiSizeXS}); // Width minus margin around each flex itm -} - -@include euiBreakpoint('xs', 's') { - .globalFilterGroup__wrapper-isVisible { - // EUI Flexbox adds too much margin between responded items, this just moves it up - margin-top: $euiSize * -1; - } -} diff --git a/src/plugins/unified_search/public/filter_bar/_index.scss b/src/plugins/unified_search/public/filter_bar/_index.scss deleted file mode 100644 index 5333aff8b87da36..000000000000000 --- a/src/plugins/unified_search/public/filter_bar/_index.scss +++ /dev/null @@ -1,3 +0,0 @@ -@import 'variables'; -@import 'global_filter_group'; -@import 'global_filter_item'; diff --git a/src/plugins/unified_search/public/filter_bar/filter_bar.styles.ts b/src/plugins/unified_search/public/filter_bar/filter_bar.styles.ts new file mode 100644 index 000000000000000..919655e0af16046 --- /dev/null +++ b/src/plugins/unified_search/public/filter_bar/filter_bar.styles.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { UseEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; + +export const filterBarStyles = ({ euiTheme }: UseEuiTheme, afterQueryBar?: boolean) => { + return { + group: css` + gap: ${euiTheme.size.xs}; + + &:not(:empty) { + margin-top: ${afterQueryBar ? euiTheme.size.s : 0}; + } + `, + }; +}; diff --git a/src/plugins/unified_search/public/filter_bar/filter_bar.tsx b/src/plugins/unified_search/public/filter_bar/filter_bar.tsx index 43b511b2c9f7d7d..3eda5ded370784d 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_bar.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_bar.tsx @@ -6,224 +6,51 @@ * Side Public License, v 1. */ -import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiPopover } from '@elastic/eui'; -import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n-react'; -import { - buildEmptyFilter, - Filter, - enableFilter, - disableFilter, - pinFilter, - toggleFilterDisabled, - toggleFilterNegated, - unpinFilter, -} from '@kbn/es-query'; -import classNames from 'classnames'; -import React, { useState, useRef } from 'react'; - -import { METRIC_TYPE } from '@kbn/analytics'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { UI_SETTINGS } from '@kbn/data-plugin/common'; -import { IDataPluginServices } from '@kbn/data-plugin/public'; +import { EuiFlexGroup, useEuiTheme } from '@elastic/eui'; +import { InjectedIntl, injectI18n } from '@kbn/i18n-react'; +import type { Filter } from '@kbn/es-query'; +import React, { useRef } from 'react'; import { DataView } from '@kbn/data-views-plugin/public'; -import { FilterOptions } from './filter_options'; -import { FILTER_EDITOR_WIDTH, FilterItem } from './filter_item'; -import { FilterEditor } from './filter_editor'; +import FilterItems, { Props as FilterItemsProps } from './filter_item/filter_items'; + +import { filterBarStyles } from './filter_bar.styles'; export interface Props { filters: Filter[]; onFiltersUpdated?: (filters: Filter[]) => void; - className: string; + className?: string; indexPatterns: DataView[]; intl: InjectedIntl; - appName: string; timeRangeForSuggestionsOverride?: boolean; + hiddenPanelOptions?: FilterItemsProps['hiddenPanelOptions']; + /** + * Applies extra styles necessary when coupled with the query bar + */ + afterQueryBar?: boolean; } const FilterBarUI = React.memo(function FilterBarUI(props: Props) { + const euiTheme = useEuiTheme(); + const styles = filterBarStyles(euiTheme, props.afterQueryBar); const groupRef = useRef(null); - const [isAddFilterPopoverOpen, setIsAddFilterPopoverOpen] = useState(false); - const kibana = useKibana(); - const { appName, usageCollection, uiSettings } = kibana.services; - if (!uiSettings) return null; - - const reportUiCounter = usageCollection?.reportUiCounter.bind(usageCollection, appName); - - function onFiltersUpdated(filters: Filter[]) { - if (props.onFiltersUpdated) { - props.onFiltersUpdated(filters); - } - } - - const onAddFilterClick = () => setIsAddFilterPopoverOpen(!isAddFilterPopoverOpen); - - function renderItems() { - return props.filters.map((filter, i) => ( - - onUpdate(i, newFilter)} - onRemove={() => onRemove(i)} - indexPatterns={props.indexPatterns} - uiSettings={uiSettings!} - timeRangeForSuggestionsOverride={props.timeRangeForSuggestionsOverride} - /> - - )); - } - - function renderAddFilter() { - const isPinned = uiSettings!.get(UI_SETTINGS.FILTERS_PINNED_BY_DEFAULT); - const [indexPattern] = props.indexPatterns; - const index = indexPattern && indexPattern.id; - const newFilter = buildEmptyFilter(isPinned, index); - - const button = ( - - +{' '} - - - ); - - return ( - - setIsAddFilterPopoverOpen(false)} - anchorPosition="downLeft" - panelPaddingSize="none" - initialFocus=".filterEditor__hiddenItem" - ownFocus - repositionOnScroll - > - -
    - setIsAddFilterPopoverOpen(false)} - key={JSON.stringify(newFilter)} - timeRangeForSuggestionsOverride={props.timeRangeForSuggestionsOverride} - /> -
    -
    -
    -
    - ); - } - - function onAdd(filter: Filter) { - reportUiCounter?.(METRIC_TYPE.CLICK, `filter:added`); - setIsAddFilterPopoverOpen(false); - - const filters = [...props.filters, filter]; - onFiltersUpdated(filters); - } - - function onRemove(i: number) { - reportUiCounter?.(METRIC_TYPE.CLICK, `filter:removed`); - const filters = [...props.filters]; - filters.splice(i, 1); - onFiltersUpdated(filters); - groupRef.current?.focus(); - } - - function onUpdate(i: number, filter: Filter) { - reportUiCounter?.(METRIC_TYPE.CLICK, `filter:edited`); - const filters = [...props.filters]; - filters[i] = filter; - onFiltersUpdated(filters); - } - - function onEnableAll() { - reportUiCounter?.(METRIC_TYPE.CLICK, `filter:enable_all`); - const filters = props.filters.map(enableFilter); - onFiltersUpdated(filters); - } - - function onDisableAll() { - reportUiCounter?.(METRIC_TYPE.CLICK, `filter:disable_all`); - const filters = props.filters.map(disableFilter); - onFiltersUpdated(filters); - } - - function onPinAll() { - reportUiCounter?.(METRIC_TYPE.CLICK, `filter:pin_all`); - const filters = props.filters.map(pinFilter); - onFiltersUpdated(filters); - } - - function onUnpinAll() { - reportUiCounter?.(METRIC_TYPE.CLICK, `filter:unpin_all`); - const filters = props.filters.map(unpinFilter); - onFiltersUpdated(filters); - } - - function onToggleAllNegated() { - reportUiCounter?.(METRIC_TYPE.CLICK, `filter:invert_all`); - const filters = props.filters.map(toggleFilterNegated); - onFiltersUpdated(filters); - } - - function onToggleAllDisabled() { - reportUiCounter?.(METRIC_TYPE.CLICK, `filter:toggle_all`); - const filters = props.filters.map(toggleFilterDisabled); - onFiltersUpdated(filters); - } - - function onRemoveAll() { - reportUiCounter?.(METRIC_TYPE.CLICK, `filter:remove_all`); - onFiltersUpdated([]); - } - - const classes = classNames('globalFilterBar', props.className); return ( - - - - - - - {renderItems()} - {renderAddFilter()} - - + ); }); diff --git a/src/plugins/unified_search/public/filter_bar/filter_button_group/filter_button_group.scss b/src/plugins/unified_search/public/filter_bar/filter_button_group/filter_button_group.scss new file mode 100644 index 000000000000000..95b87e1d827c603 --- /dev/null +++ b/src/plugins/unified_search/public/filter_bar/filter_button_group/filter_button_group.scss @@ -0,0 +1,24 @@ +.kbnFilterButtonGroup { + height: $euiFormControlHeight; + background-color: $euiFormInputGroupLabelBackground; + border-radius: $euiFormControlBorderRadius; + box-shadow: 0 0 1px inset rgba($euiFormBorderOpaqueColor, .4); + + // Targets any interactable elements + *:enabled { + transform: none !important; + } + + &--s { + height: $euiFormControlCompressedHeight; + } + + &--attached { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + > *:not(:last-of-type) { + border-right: 1px solid $euiFormBorderColor; + } +} diff --git a/src/plugins/unified_search/public/filter_bar/filter_button_group/filter_button_group.tsx b/src/plugins/unified_search/public/filter_bar/filter_button_group/filter_button_group.tsx new file mode 100644 index 000000000000000..1de5c71f4a301e2 --- /dev/null +++ b/src/plugins/unified_search/public/filter_bar/filter_button_group/filter_button_group.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 './filter_button_group.scss'; + +import React, { FC, ReactNode } from 'react'; +import classNames from 'classnames'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +interface Props { + items: ReactNode[]; + /** + * Displays the last item without a border radius as if attached to the next DOM node + */ + attached?: boolean; + /** + * Matches overall height with standard form/button sizes + */ + size?: 'm' | 's'; +} + +export const FilterButtonGroup: FC = ({ items, attached, size = 'm', ...rest }: Props) => { + return ( + + {items.map((item, i) => + item == null ? undefined : ( + + {item} + + ) + )} + + ); +}; diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/index.tsx b/src/plugins/unified_search/public/filter_bar/filter_editor/index.tsx index 972bf657723fc11..490d6480b28c95d 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/index.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/index.tsx @@ -14,6 +14,7 @@ import { EuiFlexItem, EuiForm, EuiFormRow, + EuiPopoverFooter, EuiPopoverTitle, EuiSpacer, EuiSwitch, @@ -55,6 +56,7 @@ export interface Props { onCancel: () => void; intl: InjectedIntl; timeRangeForSuggestionsOverride?: boolean; + mode?: 'edit' | 'add'; } interface State { @@ -68,6 +70,20 @@ interface State { isCustomEditorOpen: boolean; } +const panelTitleAdd = i18n.translate('unifiedSearch.filter.filterEditor.addFilterPopupTitle', { + defaultMessage: 'Add filter', +}); +const panelTitleEdit = i18n.translate('unifiedSearch.filter.filterEditor.editFilterPopupTitle', { + defaultMessage: 'Edit filter', +}); + +const addButtonLabel = i18n.translate('unifiedSearch.filter.filterEditor.addButtonLabel', { + defaultMessage: 'Add filter', +}); +const updateButtonLabel = i18n.translate('unifiedSearch.filter.filterEditor.updateButtonLabel', { + defaultMessage: 'Update filter', +}); + class FilterEditorUI extends Component { constructor(props: Props) { super(props); @@ -86,14 +102,9 @@ class FilterEditorUI extends Component { public render() { return (
    - + - - - + {this.props.mode === 'add' ? panelTitleAdd : panelTitleEdit} { -
    - + +
    {this.renderIndexPatternInput()} {this.state.isCustomEditorOpen ? this.renderCustomEditor() : this.renderRegularEditor()} @@ -154,9 +165,9 @@ class FilterEditorUI extends Component {
    )} +
    - - + { isDisabled={!this.isFilterValid()} data-test-subj="saveFilter" > - + {this.props.mode === 'add' ? addButtonLabel : updateButtonLabel} @@ -185,8 +193,8 @@ class FilterEditorUI extends Component { - -
    + +
    ); } @@ -207,32 +215,31 @@ class FilterEditorUI extends Component { } const { selectedIndexPattern } = this.state; return ( - - - + + - indexPattern.title} - onChange={this.onIndexPatternChange} - singleSelection={{ asPlainText: true }} - isClearable={false} - data-test-subj="filterIndexPatternsSelect" - /> - - - + options={this.props.indexPatterns} + selectedOptions={selectedIndexPattern ? [selectedIndexPattern] : []} + getLabel={(indexPattern) => indexPattern.title} + onChange={this.onIndexPatternChange} + singleSelection={{ asPlainText: true }} + isClearable={false} + data-test-subj="filterIndexPatternsSelect" + /> + + + ); } diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_label.tsx b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_label.tsx index 3f2aaa50af0fcf9..601cf68141c4992 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_label.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/lib/filter_label.tsx @@ -11,7 +11,7 @@ import { EuiTextColor } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Filter, FILTERS } from '@kbn/data-plugin/common'; import { existsOperator, isOneOfOperator } from './filter_operators'; -import type { FilterLabelStatus } from '../../filter_item'; +import type { FilterLabelStatus } from '../../filter_item/filter_item'; export interface FilterLabelProps { filter: Filter; diff --git a/src/plugins/unified_search/public/filter_bar/_variables.scss b/src/plugins/unified_search/public/filter_bar/filter_item/_variables.scss similarity index 100% rename from src/plugins/unified_search/public/filter_bar/_variables.scss rename to src/plugins/unified_search/public/filter_bar/filter_item/_variables.scss diff --git a/src/plugins/unified_search/public/filter_bar/_global_filter_item.scss b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.scss similarity index 82% rename from src/plugins/unified_search/public/filter_bar/_global_filter_item.scss rename to src/plugins/unified_search/public/filter_bar/filter_item/filter_item.scss index 1c9cea729177082..94f64bdce2f65d2 100644 --- a/src/plugins/unified_search/public/filter_bar/_global_filter_item.scss +++ b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.scss @@ -1,3 +1,5 @@ +@import './variables'; + /** * 1. Allow wrapping of long filter items */ @@ -6,26 +8,14 @@ line-height: $euiSize; border: none; color: $euiTextColor; - padding-top: $euiSizeM / 2; - padding-bottom: $euiSizeM / 2; + padding-top: $euiSizeM / 2 + 1px; + padding-bottom: $euiSizeM / 2 + 1px; white-space: normal; /* 1 */ - .euiBadge__childButton { - flex-shrink: 1; /* 1 */ - } - - .euiBadge__iconButton:focus { - background-color: transparentize($euiColorPrimary, .9); - } - &:not(.globalFilterItem-isDisabled) { @include euiFormControlDefaultShadow; box-shadow: #{$euiFormControlBoxShadow}, inset 0 0 0 1px $kbnGlobalFilterItemBorderColor; // Make the actual border more visible } - - &:focus-within { - animation: none !important; // Remove focus ring animation otherwise it overrides simulated border via box-shadow - } } .globalFilterItem-isDisabled { @@ -81,7 +71,7 @@ } .globalFilterItem__editorForm { - padding: $euiSizeM; + padding: $euiSizeS; } .globalFilterItem__popover, diff --git a/src/plugins/unified_search/public/filter_bar/filter_item.tsx b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx similarity index 96% rename from src/plugins/unified_search/public/filter_bar/filter_item.tsx rename to src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx index 16234a2953dc7ad..8bdffa5e989d022 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_item.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_item/filter_item.tsx @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import './filter_item.scss'; + import { EuiContextMenu, EuiPopover, EuiPopoverProps } from '@elastic/eui'; import { InjectedIntl } from '@kbn/i18n-react'; import { @@ -24,11 +26,10 @@ import { getDisplayValueFromFilter, getFieldDisplayValueFromFilter, } from '@kbn/data-plugin/public'; -import { FilterEditor } from './filter_editor'; -import { FilterView } from './filter_view'; -import { getIndexPatterns } from '../services'; - -type PanelOptions = 'pinFilter' | 'editFilter' | 'negateFilter' | 'disableFilter' | 'deleteFilter'; +import { FilterEditor } from '../filter_editor'; +import { FilterView } from '../filter_view'; +import { getIndexPatterns } from '../../services'; +import { FilterPanelOption } from '../../types'; export interface FilterItemProps { id: string; @@ -39,7 +40,7 @@ export interface FilterItemProps { onRemove: () => void; intl: InjectedIntl; uiSettings: IUiSettingsClient; - hiddenPanelOptions?: PanelOptions[]; + hiddenPanelOptions?: FilterPanelOption[]; timeRangeForSuggestionsOverride?: boolean; readonly?: boolean; } @@ -101,6 +102,11 @@ export function FilterItem(props: FilterItemProps) { } } + function handleIconClick(e: MouseEvent) { + props.onRemove(); + setIsPopoverOpen(false); + } + function onSubmit(f: Filter) { setIsPopoverOpen(false); props.onUpdate(f); @@ -241,7 +247,7 @@ export function FilterItem(props: FilterItemProps) { if (hiddenPanelOptions && hiddenPanelOptions.length > 0) { mainPanelItems = mainPanelItems.filter( - (pItem) => !hiddenPanelOptions.includes(pItem['data-test-subj'] as PanelOptions) + (pItem) => !hiddenPanelOptions.includes(pItem['data-test-subj'] as FilterPanelOption) ); } return [ @@ -363,7 +369,7 @@ export function FilterItem(props: FilterItemProps) { filterLabelStatus: valueLabelConfig.status, errorMessage: valueLabelConfig.message, className: getClasses(!!filter.meta.negate, valueLabelConfig), - iconOnClick: props.onRemove, + iconOnClick: handleIconClick, onClick: handleBadgeClick, 'data-test-subj': getDataTestSubj(valueLabelConfig), readonly: props.readonly, diff --git a/src/plugins/unified_search/public/filter_bar/filter_item/filter_items.tsx b/src/plugins/unified_search/public/filter_bar/filter_item/filter_items.tsx new file mode 100644 index 000000000000000..0119bf10cfa2775 --- /dev/null +++ b/src/plugins/unified_search/public/filter_bar/filter_item/filter_items.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 React, { useRef } from 'react'; +import { css } from '@emotion/react'; +import { EuiFlexItem } from '@elastic/eui'; +import { InjectedIntl, injectI18n } from '@kbn/i18n-react'; +import type { Filter } from '@kbn/es-query'; +import { IDataPluginServices } from '@kbn/data-plugin/public'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { DataView } from '@kbn/data-views-plugin/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { FilterItem, FilterItemProps } from './filter_item'; + +export interface Props { + filters: Filter[]; + onFiltersUpdated?: (filters: Filter[]) => void; + indexPatterns: DataView[]; + intl: InjectedIntl; + timeRangeForSuggestionsOverride?: boolean; + hiddenPanelOptions?: FilterItemProps['hiddenPanelOptions']; +} + +const FilterItemsUI = React.memo(function FilterItemsUI(props: Props) { + const groupRef = useRef(null); + const kibana = useKibana(); + const { appName, usageCollection, uiSettings } = kibana.services; + if (!uiSettings) return null; + + const reportUiCounter = usageCollection?.reportUiCounter.bind(usageCollection, appName); + + function onFiltersUpdated(filters: Filter[]) { + if (props.onFiltersUpdated) { + props.onFiltersUpdated(filters); + } + } + + function renderItems() { + return props.filters.map((filter, i) => ( + + onUpdate(i, newFilter)} + onRemove={() => onRemove(i)} + indexPatterns={props.indexPatterns} + uiSettings={uiSettings!} + hiddenPanelOptions={props.hiddenPanelOptions} + timeRangeForSuggestionsOverride={props.timeRangeForSuggestionsOverride} + /> + + )); + } + + function onRemove(i: number) { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:removed`); + const filters = [...props.filters]; + filters.splice(i, 1); + onFiltersUpdated(filters); + groupRef.current?.focus(); + } + + function onUpdate(i: number, filter: Filter) { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:edited`); + const filters = [...props.filters]; + filters[i] = filter; + onFiltersUpdated(filters); + } + + return <>{renderItems()}; +}); + +const FilterItems = injectI18n(FilterItemsUI); +// Needed for React.lazy +// eslint-disable-next-line import/no-default-export +export default FilterItems; diff --git a/src/plugins/unified_search/public/filter_bar/filter_options.tsx b/src/plugins/unified_search/public/filter_bar/filter_options.tsx deleted file mode 100644 index d2e229c9887119b..000000000000000 --- a/src/plugins/unified_search/public/filter_bar/filter_options.tsx +++ /dev/null @@ -1,176 +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 { EuiButtonIcon, EuiContextMenu, EuiPopover, EuiPopoverTitle } from '@elastic/eui'; -import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n-react'; -import { Component } from 'react'; -import React from 'react'; - -interface Props { - onEnableAll: () => void; - onDisableAll: () => void; - onPinAll: () => void; - onUnpinAll: () => void; - onToggleAllNegated: () => void; - onToggleAllDisabled: () => void; - onRemoveAll: () => void; - intl: InjectedIntl; -} - -interface State { - isPopoverOpen: boolean; -} - -class FilterOptionsUI extends Component { - private buttonRef = React.createRef(); - - public state: State = { - isPopoverOpen: false, - }; - - public togglePopover = () => { - this.setState((prevState) => ({ - isPopoverOpen: !prevState.isPopoverOpen, - })); - }; - - public closePopover = () => { - this.setState({ isPopoverOpen: false }); - this.buttonRef.current?.focus(); - }; - - public render() { - const panelTree = { - id: 0, - items: [ - { - name: this.props.intl.formatMessage({ - id: 'unifiedSearch.filter.options.enableAllFiltersButtonLabel', - defaultMessage: 'Enable all', - }), - icon: 'eye', - onClick: () => { - this.closePopover(); - this.props.onEnableAll(); - }, - 'data-test-subj': 'enableAllFilters', - }, - { - name: this.props.intl.formatMessage({ - id: 'unifiedSearch.filter.options.disableAllFiltersButtonLabel', - defaultMessage: 'Disable all', - }), - icon: 'eyeClosed', - onClick: () => { - this.closePopover(); - this.props.onDisableAll(); - }, - 'data-test-subj': 'disableAllFilters', - }, - { - name: this.props.intl.formatMessage({ - id: 'unifiedSearch.filter.options.pinAllFiltersButtonLabel', - defaultMessage: 'Pin all', - }), - icon: 'pin', - onClick: () => { - this.closePopover(); - this.props.onPinAll(); - }, - 'data-test-subj': 'pinAllFilters', - }, - { - name: this.props.intl.formatMessage({ - id: 'unifiedSearch.filter.options.unpinAllFiltersButtonLabel', - defaultMessage: 'Unpin all', - }), - icon: 'pin', - onClick: () => { - this.closePopover(); - this.props.onUnpinAll(); - }, - 'data-test-subj': 'unpinAllFilters', - }, - { - name: this.props.intl.formatMessage({ - id: 'unifiedSearch.filter.options.invertNegatedFiltersButtonLabel', - defaultMessage: 'Invert inclusion', - }), - icon: 'invert', - onClick: () => { - this.closePopover(); - this.props.onToggleAllNegated(); - }, - 'data-test-subj': 'invertInclusionAllFilters', - }, - { - name: this.props.intl.formatMessage({ - id: 'unifiedSearch.filter.options.invertDisabledFiltersButtonLabel', - defaultMessage: 'Invert enabled/disabled', - }), - icon: 'eye', - onClick: () => { - this.closePopover(); - this.props.onToggleAllDisabled(); - }, - 'data-test-subj': 'invertEnableDisableAllFilters', - }, - { - name: this.props.intl.formatMessage({ - id: 'unifiedSearch.filter.options.deleteAllFiltersButtonLabel', - defaultMessage: 'Remove all', - }), - icon: 'trash', - onClick: () => { - this.closePopover(); - this.props.onRemoveAll(); - }, - 'data-test-subj': 'removeAllFilters', - }, - ], - }; - - return ( - - } - anchorPosition="rightUp" - panelPaddingSize="none" - repositionOnScroll - > - - - - - - ); - } -} - -export const FilterOptions = injectI18n(FilterOptionsUI); diff --git a/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx b/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx index 29ff160d50db6a7..d399bb0025a1096 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_view/index.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import React, { FC } from 'react'; import { Filter, isFilterPinned } from '@kbn/es-query'; import { FilterLabel } from '..'; -import type { FilterLabelStatus } from '../filter_item'; +import type { FilterLabelStatus } from '../filter_item/filter_item'; interface Props { filter: Filter; diff --git a/src/plugins/unified_search/public/filter_bar/index.tsx b/src/plugins/unified_search/public/filter_bar/index.tsx index 70a108f35979063..30f94c3972ee103 100644 --- a/src/plugins/unified_search/public/filter_bar/index.tsx +++ b/src/plugins/unified_search/public/filter_bar/index.tsx @@ -17,6 +17,13 @@ export const FilterBar = (props: React.ComponentProps) => ); +const LazyFilterItems = React.lazy(() => import('./filter_item/filter_items')); +export const FilterItems = (props: React.ComponentProps) => ( + }> + + +); + const LazyFilterLabel = React.lazy(() => import('./filter_editor/lib/filter_label')); export const FilterLabel = (props: React.ComponentProps) => ( }> @@ -24,7 +31,7 @@ export const FilterLabel = (props: React.ComponentProps) ); -const LazyFilterItem = React.lazy(() => import('./filter_item')); +const LazyFilterItem = React.lazy(() => import('./filter_item/filter_item')); export const FilterItem = (props: React.ComponentProps) => ( }> diff --git a/src/plugins/unified_search/public/index.scss b/src/plugins/unified_search/public/index.scss old mode 100755 new mode 100644 index 7f7704c64e9b41f..d19013fb408a3aa --- a/src/plugins/unified_search/public/index.scss +++ b/src/plugins/unified_search/public/index.scss @@ -1,7 +1 @@ -@import './typeahead/index'; - -@import './saved_query_management/index'; - -@import './query_string_input/index'; - -@import './filter_bar/index'; +@import './typeahead/suggestion'; \ No newline at end of file diff --git a/src/plugins/unified_search/public/index.ts b/src/plugins/unified_search/public/index.ts index 93805c6cfec1c01..bc7974b42efb35d 100755 --- a/src/plugins/unified_search/public/index.ts +++ b/src/plugins/unified_search/public/index.ts @@ -15,6 +15,8 @@ export type { StatefulSearchBarProps, SearchBarProps } from './search_bar'; export type { UnifiedSearchPublicPluginStart, UnifiedSearchPluginSetup } from './types'; export { SearchBar } from './search_bar'; export { FilterLabel, FilterItem } from './filter_bar'; +export { DataViewsList } from './dataview_picker/dataview_list'; +export { DataViewPicker } from './dataview_picker'; export type { ApplyGlobalFilterActionContext } from './actions'; export { ACTION_GLOBAL_APPLY_FILTER } from './actions'; diff --git a/src/plugins/unified_search/public/index_pattern_select/create_index_pattern_select.tsx b/src/plugins/unified_search/public/index_pattern_select/create_index_pattern_select.tsx index 505b1deda7cdfc5..04b15aac847783f 100644 --- a/src/plugins/unified_search/public/index_pattern_select/create_index_pattern_select.tsx +++ b/src/plugins/unified_search/public/index_pattern_select/create_index_pattern_select.tsx @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import _ from 'lodash'; import React from 'react'; import { IndexPatternsContract } from '@kbn/data-plugin/public'; diff --git a/src/plugins/unified_search/public/plugin.ts b/src/plugins/unified_search/public/plugin.ts index 5ba247406627597..59c327449d354bc 100755 --- a/src/plugins/unified_search/public/plugin.ts +++ b/src/plugins/unified_search/public/plugin.ts @@ -5,9 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - -import './index.scss'; - import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; import { Storage, IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; @@ -26,6 +23,8 @@ import type { import { createFilterAction } from './actions/apply_filter_action'; import { ACTION_GLOBAL_APPLY_FILTER } from './actions'; +import './index.scss'; + export class UnifiedSearchPublicPlugin implements Plugin { @@ -58,7 +57,7 @@ export class UnifiedSearchPublicPlugin public start( core: CoreStart, - { data, dataViews, uiActions }: UnifiedSearchStartDependencies + { data, dataViews, uiActions, screenshotMode }: UnifiedSearchStartDependencies ): UnifiedSearchPublicPluginStart { setTheme(core.theme); setOverlays(core.overlays); @@ -71,6 +70,7 @@ export class UnifiedSearchPublicPlugin data, storage: this.storage, usageCollection: this.usageCollection, + isScreenshotMode: Boolean(screenshotMode?.isScreenshotMode()), }); uiActions.addTriggerAction( diff --git a/src/plugins/unified_search/public/query_string_input/_index.scss b/src/plugins/unified_search/public/query_string_input/_index.scss deleted file mode 100644 index 1d955920b8e1327..000000000000000 --- a/src/plugins/unified_search/public/query_string_input/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import './query_bar'; diff --git a/src/plugins/unified_search/public/query_string_input/_query_bar.scss b/src/plugins/unified_search/public/query_string_input/_query_bar.scss deleted file mode 100644 index f8c2f067d9ec5c6..000000000000000 --- a/src/plugins/unified_search/public/query_string_input/_query_bar.scss +++ /dev/null @@ -1,123 +0,0 @@ -.kbnQueryBar__wrap { - max-width: 100%; - z-index: $euiZContentMenu; -} - -// Uses the append style, but no bordering -.kqlQueryBar__languageSwitcherButton { - border-right: none !important; - border-left: $euiFormInputGroupBorder; -} - -.kbnQueryBar__textareaWrap { - overflow: visible !important; // Override EUI form control - display: flex; - flex: 1 1 100%; - position: relative; - background-color: $euiFormBackgroundColor; - border-radius: $euiFormControlBorderRadius; - - &.kbnQueryBar__textareaWrap--hasPrepend { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } - - &.kbnQueryBar__textareaWrap--hasAppend { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } -} - -.kbnQueryBar__textarea { - z-index: $euiZContentMenu; - resize: none !important; // When in the group, it will autosize - height: $euiFormControlHeight - 2px; - // Unlike most inputs within layout control groups, the text area still needs a border - // for multi-line content. These adjusts help it sit above the control groups - // shadow to line up correctly. - padding: $euiSizeS; - box-shadow: 0 0 0 1px $euiFormBorderColor; - padding-bottom: $euiSizeS + 1px; - // Firefox adds margin to textarea - margin: 0; - - &.kbnQueryBar__textarea--hasPrepend { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } - &.kbnQueryBar__textarea--hasAppend { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - - &:not(.kbnQueryBar__textarea--autoHeight):not(:invalid) { - @include euiYScrollWithShadows; - } - - &:not(.kbnQueryBar__textarea--autoHeight) { - white-space: nowrap; - overflow-y: hidden; - overflow-x: hidden; - } - - // When focused, let it scroll - &.kbnQueryBar__textarea--autoHeight { - overflow-x: auto; - overflow-y: auto; - white-space: normal; - box-shadow: 0 0 0 1px $euiFormBorderColor; - } - - @include euiFormControlWithIcon($isIconOptional: true); - ~ .euiFormControlLayoutIcons { - // By default form control layout icon is vertically centered, but our textarea - // can expand to be multi-line, so we position it with padding that matches - // the parent textarea padding - z-index: $euiZContentMenu + 1; - top: $euiSizeS + 3px; - bottom: unset; - } -} - -.kbnQueryBar__datePickerWrapper { - .euiDatePopoverButton-isInvalid { - background-image: euiFormControlGradient($euiColorDanger); - - // @todo Remove when EUI issue is resolved. - // @see https://github.com/elastic/eui/issues/4612 - &:focus { - color: $euiTextColor; - background-color: $euiFormBackgroundColor; - background-image: euiFormControlGradient($euiColorPrimary); - } - } -} - -@include euiBreakpoint('xs', 's') { - .kbnQueryBar--withDatePicker { - > :first-child { - // Change the order of the query bar and date picker so that the date picker is top and the query bar still aligns with filters - order: 1; - // EUI Flexbox adds too much margin between responded items, this just moves it up - margin-top: $euiSizeS * -1; - } - } -} - -// IE specific fix for the datepicker to not collapse -@include euiBreakpoint('m', 'l', 'xl') { - .kbnQueryBar__datePickerWrapper { - max-width: 40vw; - // sass-lint:disable-block no-important - flex-grow: 0 !important; - flex-basis: auto !important; - - &.kbnQueryBar__datePickerWrapper-isHidden { - // sass-lint:disable-block no-important - margin-right: -$euiSizeXS !important; - width: 0; - overflow: hidden; - max-width: 0; - } - } -} diff --git a/src/plugins/unified_search/public/query_string_input/add_filter_popover.tsx b/src/plugins/unified_search/public/query_string_input/add_filter_popover.tsx new file mode 100644 index 000000000000000..b86d7d7f02498a0 --- /dev/null +++ b/src/plugins/unified_search/public/query_string_input/add_filter_popover.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFlexItem, + EuiButtonIcon, + EuiPopover, + EuiButtonIconProps, + EuiToolTip, +} from '@elastic/eui'; +import { Filter } from '@kbn/es-query'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import { FilterEditorWrapper } from './filter_editor_wrapper'; + +interface AddFilterPopoverProps { + indexPatterns?: Array; + filters: Filter[]; + timeRangeForSuggestionsOverride?: boolean; + onFiltersUpdated?: (filters: Filter[]) => void; + buttonProps?: Partial; +} + +export const AddFilterPopover = React.memo(function AddFilterPopover({ + indexPatterns, + filters, + timeRangeForSuggestionsOverride, + onFiltersUpdated, + buttonProps, +}: AddFilterPopoverProps) { + const [isAddFilterPopoverOpen, setIsAddFilterPopoverOpen] = useState(false); + + const buttonIconLabel = i18n.translate('unifiedSearch.filter.filterBar.addFilterButtonLabel', { + defaultMessage: 'Add filter', + }); + + const button = ( + + setIsAddFilterPopoverOpen(!isAddFilterPopoverOpen)} + size="m" + {...buttonProps} + /> + + ); + + return ( + + setIsAddFilterPopoverOpen(false)} + anchorPosition="downLeft" + panelPaddingSize="none" + initialFocus=".filterEditor__hiddenItem" + ownFocus + repositionOnScroll + > + setIsAddFilterPopoverOpen(false)} + /> + + + ); +}); diff --git a/src/plugins/unified_search/public/query_string_input/filter_editor_wrapper.tsx b/src/plugins/unified_search/public/query_string_input/filter_editor_wrapper.tsx new file mode 100644 index 000000000000000..dd106607353f2f1 --- /dev/null +++ b/src/plugins/unified_search/public/query_string_input/filter_editor_wrapper.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 React, { useState, useEffect } from 'react'; +import { Filter, buildEmptyFilter } from '@kbn/es-query'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { UI_SETTINGS } from '@kbn/data-plugin/common'; +import { IDataPluginServices } from '@kbn/data-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import { FILTER_EDITOR_WIDTH } from '../filter_bar/filter_item/filter_item'; +import { FilterEditor } from '../filter_bar/filter_editor'; +import { fetchIndexPatterns } from './fetch_index_patterns'; + +interface FilterEditorWrapperProps { + indexPatterns?: Array; + filters: Filter[]; + timeRangeForSuggestionsOverride?: boolean; + closePopover?: () => void; + onFiltersUpdated?: (filters: Filter[]) => void; +} + +export const FilterEditorWrapper = React.memo(function FilterEditorWrapper({ + indexPatterns, + filters, + timeRangeForSuggestionsOverride, + closePopover, + onFiltersUpdated, +}: FilterEditorWrapperProps) { + const kibana = useKibana(); + const { uiSettings, data, usageCollection, appName } = kibana.services; + const reportUiCounter = usageCollection?.reportUiCounter.bind(usageCollection, appName); + const [dataViews, setDataviews] = useState([]); + const [newFilter, setNewFilter] = useState(undefined); + const isPinned = uiSettings!.get(UI_SETTINGS.FILTERS_PINNED_BY_DEFAULT); + + useEffect(() => { + const fetchDataViews = async () => { + const stringPatterns = indexPatterns?.filter( + (indexPattern) => typeof indexPattern === 'string' + ) as string[]; + const objectPatterns = indexPatterns?.filter( + (indexPattern) => typeof indexPattern !== 'string' + ) as DataView[]; + + const objectPatternsFromStrings = (await fetchIndexPatterns( + data.dataViews, + stringPatterns + )) as DataView[]; + setDataviews([...objectPatterns, ...objectPatternsFromStrings]); + const [dataView] = [...objectPatterns, ...objectPatternsFromStrings]; + const index = dataView && dataView.id; + const emptyFilter = buildEmptyFilter(isPinned, index); + setNewFilter(emptyFilter); + }; + if (indexPatterns) { + fetchDataViews(); + } + }, [data.dataViews, indexPatterns, isPinned]); + + function onAdd(filter: Filter) { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:added`); + closePopover?.(); + const updatedFilters = [...filters, filter]; + onFiltersUpdated?.(updatedFilters); + } + + return ( +
    + {newFilter && ( + closePopover?.()} + key={JSON.stringify(newFilter)} + timeRangeForSuggestionsOverride={timeRangeForSuggestionsOverride} + mode="add" + /> + )} +
    + ); +}); diff --git a/src/plugins/unified_search/public/query_string_input/language_switcher.test.tsx b/src/plugins/unified_search/public/query_string_input/language_switcher.test.tsx index 0223fc85a3ddbe9..591fe9436079353 100644 --- a/src/plugins/unified_search/public/query_string_input/language_switcher.test.tsx +++ b/src/plugins/unified_search/public/query_string_input/language_switcher.test.tsx @@ -11,7 +11,7 @@ import { QueryLanguageSwitcher, QueryLanguageSwitcherProps } from './language_sw import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { coreMock } from '@kbn/core/public/mocks'; import { mountWithIntl } from '@kbn/test-jest-helpers'; -import { EuiButtonEmpty, EuiIcon, EuiPopover } from '@elastic/eui'; +import { EuiButtonIcon, EuiIcon, EuiPopover } from '@elastic/eui'; const startMock = coreMock.createStart(); describe('LanguageSwitcher', () => { @@ -28,7 +28,7 @@ describe('LanguageSwitcher', () => { ); } - it('should toggle off if language is lucene', () => { + it('should select the lucene context menu if language is lucene', () => { const component = mountWithIntl( wrapInContext({ language: 'lucene', @@ -37,12 +37,14 @@ describe('LanguageSwitcher', () => { }, }) ); - component.find(EuiButtonEmpty).simulate('click'); + component.find(EuiButtonIcon).simulate('click'); expect(component.find(EuiPopover).prop('isOpen')).toBe(true); - expect(component.find('[data-test-subj="languageToggle"]').get(0).props.checked).toBeFalsy(); + expect(component.find('[data-test-subj="luceneLanguageMenuItem"]').get(0).props.icon).toBe( + 'check' + ); }); - it('should toggle on if language is kuery', () => { + it('should select the kql context menu if language is kuery', () => { const component = mountWithIntl( wrapInContext({ language: 'kuery', @@ -51,12 +53,14 @@ describe('LanguageSwitcher', () => { }, }) ); - component.find(EuiButtonEmpty).simulate('click'); + component.find(EuiButtonIcon).simulate('click'); expect(component.find(EuiPopover).prop('isOpen')).toBe(true); - expect(component.find('[data-test-subj="languageToggle"]').get(0).props.checked).toBeTruthy(); + expect(component.find('[data-test-subj="kqlLanguageMenuItem"]').get(0).props.icon).toBe( + 'check' + ); }); - it('should toggle off if language is text', () => { + it('should select the lucene context menu if language is text', () => { const component = mountWithIntl( wrapInContext({ language: 'text', @@ -65,9 +69,11 @@ describe('LanguageSwitcher', () => { }, }) ); - component.find(EuiButtonEmpty).simulate('click'); + component.find(EuiButtonIcon).simulate('click'); expect(component.find(EuiPopover).prop('isOpen')).toBe(true); - expect(component.find('[data-test-subj="languageToggle"]').get(0).props.checked).toBeFalsy(); + expect(component.find('[data-test-subj="luceneLanguageMenuItem"]').get(0).props.icon).toBe( + 'check' + ); }); it('it set language on nonKql mode text', () => { const onSelectLanguage = jest.fn(); @@ -79,11 +85,13 @@ describe('LanguageSwitcher', () => { onSelectLanguage, }) ); - component.find(EuiButtonEmpty).simulate('click'); + component.find(EuiButtonIcon).simulate('click'); expect(component.find(EuiPopover).prop('isOpen')).toBe(true); - expect(component.find('[data-test-subj="languageToggle"]').get(0).props.checked).toBeTruthy(); + expect(component.find('[data-test-subj="kqlLanguageMenuItem"]').get(0).props.icon).toBe( + 'check' + ); - component.find('[data-test-subj="languageToggle"]').at(1).simulate('click'); + component.find('[data-test-subj="luceneLanguageMenuItem"]').at(1).simulate('click'); expect(onSelectLanguage).toHaveBeenCalledWith('text'); }); @@ -97,8 +105,8 @@ describe('LanguageSwitcher', () => { onSelectLanguage, }) ); - component.find(EuiButtonEmpty).simulate('click'); - component.find('[data-test-subj="languageToggle"]').at(1).simulate('click'); + component.find(EuiButtonIcon).simulate('click'); + component.find('[data-test-subj="luceneLanguageMenuItem"]').at(1).simulate('click'); expect(onSelectLanguage).toHaveBeenCalledWith('lucene'); }); @@ -114,10 +122,10 @@ describe('LanguageSwitcher', () => { }) ); - expect(component.find(EuiIcon).prop('type')).toBe('boxesVertical'); + expect(component.find(EuiIcon).prop('type')).toBe('filter'); - component.find(EuiButtonEmpty).simulate('click'); - component.find('[data-test-subj="languageToggle"]').at(1).simulate('click'); + component.find(EuiButtonIcon).simulate('click'); + component.find('[data-test-subj="kqlLanguageMenuItem"]').at(1).simulate('click'); expect(onSelectLanguage).toHaveBeenCalledWith('kuery'); }); @@ -132,13 +140,12 @@ describe('LanguageSwitcher', () => { onSelectLanguage, }) ); - - expect(component.find('[data-test-subj="switchQueryLanguageButton"]').at(0).text()).toBe( - 'Lucene' + component.find(EuiButtonIcon).simulate('click'); + expect(component.find('[data-test-subj="luceneLanguageMenuItem"]').get(0).props.icon).toBe( + 'check' ); - component.find(EuiButtonEmpty).simulate('click'); - component.find('[data-test-subj="languageToggle"]').at(1).simulate('click'); + component.find('[data-test-subj="kqlLanguageMenuItem"]').at(1).simulate('click'); expect(onSelectLanguage).toHaveBeenCalledWith('kuery'); }); diff --git a/src/plugins/unified_search/public/query_string_input/language_switcher.tsx b/src/plugins/unified_search/public/query_string_input/language_switcher.tsx index db42339e464c365..a48901ef17f861b 100644 --- a/src/plugins/unified_search/public/query_string_input/language_switcher.tsx +++ b/src/plugins/unified_search/public/query_string_input/language_switcher.tsx @@ -7,17 +7,13 @@ */ import { - EuiButtonEmpty, - EuiForm, - EuiFormRow, - EuiIcon, - EuiLink, EuiPopover, EuiPopoverTitle, - EuiSpacer, - EuiSwitch, - EuiText, PopoverAnchorPosition, + EuiContextMenuItem, + toSentenceCase, + EuiHorizontalRule, + EuiButtonIcon, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; @@ -29,7 +25,7 @@ export interface QueryLanguageSwitcherProps { onSelectLanguage: (newLanguage: string) => void; anchorPosition?: PopoverAnchorPosition; nonKqlMode?: 'lucene' | 'text'; - nonKqlModeHelpText?: string; + isOnTopBarMenu?: boolean; } export const QueryLanguageSwitcher = React.memo(function QueryLanguageSwitcher({ @@ -37,124 +33,78 @@ export const QueryLanguageSwitcher = React.memo(function QueryLanguageSwitcher({ anchorPosition, onSelectLanguage, nonKqlMode = 'lucene', - nonKqlModeHelpText, + isOnTopBarMenu, }: QueryLanguageSwitcherProps) { const kibana = useKibana(); const kueryQuerySyntaxDocs = kibana.services.docLinks!.links.query.kueryQuerySyntax; const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const luceneLabel = ( - - ); - const kqlLabel = ( - - ); - - const kqlFullName = ( - - ); - - const kqlModeTitle = i18n.translate('unifiedSearch.query.queryBar.languageSwitcher.toText', { - defaultMessage: 'Switch to Kibana Query Language for search', - }); const button = ( - setIsPopoverOpen(!isPopoverOpen)} className="euiFormControlLayout__append kqlQueryBar__languageSwitcherButton" data-test-subj={'switchQueryLanguageButton'} - > - {language === 'kuery' ? ( - kqlLabel - ) : nonKqlMode === 'lucene' ? ( - luceneLabel - ) : ( - - )} - + aria-label={i18n.translate('unifiedSearch.switchLanguage.buttonText', { + defaultMessage: 'Switch language button.', + })} + /> + ); + + const languageMenuItem = ( +
    + { + onSelectLanguage('kuery'); + }} + > + KQL + + { + onSelectLanguage(nonKqlMode); + }} + > + {toSentenceCase(nonKqlMode)} + + + + Documentation + +
    ); - return ( + const languageQueryStringComponent = ( setIsPopoverOpen(false)} repositionOnScroll - ownFocus={true} - initialFocus={'[role="switch"]'} + panelPaddingSize="none" > - + -
    - -

    - - {kqlFullName} - - ), - nonKqlModeHelpText: - nonKqlModeHelpText || - i18n.translate( - 'unifiedSearch.query.queryBar.syntaxOptionsDescription.nonKqlModeHelpText', - { - defaultMessage: 'Kibana uses Lucene.', - } - ), - }} - /> -

    -
    - - - - - - - ) : ( - - ) - } - checked={language === 'kuery'} - onChange={() => { - const newLanguage = language === 'kuery' ? nonKqlMode : 'kuery'; - onSelectLanguage(newLanguage); - }} - data-test-subj="languageToggle" - /> - - -
    + {languageMenuItem}
    ); + + return Boolean(isOnTopBarMenu) ? languageMenuItem : languageQueryStringComponent; }); diff --git a/src/plugins/unified_search/public/query_string_input/query_bar.scss b/src/plugins/unified_search/public/query_string_input/query_bar.scss new file mode 100644 index 000000000000000..89cca695ee15472 --- /dev/null +++ b/src/plugins/unified_search/public/query_string_input/query_bar.scss @@ -0,0 +1,13 @@ +.kbnQueryBar__datePickerWrapper { + .euiDatePopoverButton-isInvalid { + background-image: euiFormControlGradient($euiColorDanger); + + // @todo Remove when EUI issue is resolved. + // @see https://github.com/elastic/eui/issues/4612 + &:focus { + color: $euiTextColor; + background-color: $euiFormBackgroundColor; + background-image: euiFormControlGradient($euiColorPrimary); + } + } +} diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_menu.test.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_menu.test.tsx new file mode 100644 index 000000000000000..224957e25a41ded --- /dev/null +++ b/src/plugins/unified_search/public/query_string_input/query_bar_menu.test.tsx @@ -0,0 +1,346 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 React from 'react'; +import { I18nProvider } from '@kbn/i18n-react'; +import { act } from 'react-dom/test-utils'; +import { waitFor } from '@testing-library/react'; +import { mountWithIntl as mount } from '@kbn/test-jest-helpers'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { coreMock } from '@kbn/core/public/mocks'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { Filter } from '@kbn/es-query'; +import { QueryBarMenuProps, QueryBarMenu } from './query_bar_menu'; +import { EuiPopover } from '@elastic/eui'; + +describe('Querybar Menu component', () => { + const createMockWebStorage = () => ({ + clear: jest.fn(), + getItem: jest.fn(), + key: jest.fn(), + removeItem: jest.fn(), + setItem: jest.fn(), + length: 0, + }); + + const createMockStorage = () => ({ + storage: createMockWebStorage(), + get: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + clear: jest.fn(), + }); + const getStorage = (v: string) => { + const storage = createMockStorage(); + storage.get.mockReturnValue(v); + return storage; + }; + + const filtersMock = [ + { + meta: { + index: 'ff959d40-b880-11e8-a6d9-e546fe2bba5f', + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'category.keyword', + params: { + query: "Men's Accessories", + }, + }, + query: { + match_phrase: { + 'category.keyword': "Men's Accessories", + }, + }, + $state: { + store: 'appState', + }, + }, + ] as Filter[]; + + const startMock = coreMock.createStart(); + let dataMock = dataPluginMock.createStartContract(); + function wrapQueryBarMenuComponentInContext(testProps: QueryBarMenuProps, storageValue: string) { + dataMock = { + ...dataMock, + dataViews: { + ...dataMock.dataViews, + getIdsWithTitle: jest.fn(), + }, + }; + const services = { + data: dataMock, + storage: getStorage(storageValue), + uiSettings: startMock.uiSettings, + }; + + return ( + + + + + + ); + } + let props: QueryBarMenuProps; + beforeEach(() => { + props = { + language: 'kuery', + onQueryChange: jest.fn(), + onQueryBarSubmit: jest.fn(), + toggleFilterBarMenuPopover: jest.fn(), + openQueryBarMenu: false, + savedQueryService: { + ...dataMock.query.savedQueries, + findSavedQueries: jest.fn().mockResolvedValue({ + queries: [ + { + id: '8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a9', + attributes: { + title: 'Test', + description: '', + query: { + query: 'category.keyword : "Men\'s Shoes" ', + language: 'kuery', + }, + filters: [], + }, + }, + ], + }), + }, + }; + }); + it('should not render the popover if the openQueryBarMenu prop is false', async () => { + await act(async () => { + const component = mount(wrapQueryBarMenuComponentInContext(props, 'kuery')); + expect(component.find(EuiPopover).prop('isOpen')).toBe(false); + }); + }); + + it('should render the popover if the openQueryBarMenu prop is true', async () => { + const newProps = { + ...props, + openQueryBarMenu: true, + }; + await act(async () => { + const component = mount(wrapQueryBarMenuComponentInContext(newProps, 'kuery')); + expect(component.find(EuiPopover).prop('isOpen')).toBe(true); + }); + }); + + it('should render the context menu by default', async () => { + const newProps = { + ...props, + openQueryBarMenu: true, + }; + const component = mount(wrapQueryBarMenuComponentInContext(newProps, 'kuery')); + expect(component.find('[data-test-subj="queryBarMenuPanel"]')).toBeTruthy(); + }); + + it('should render the saved saved queries panels if the showQueryInput prop is true but disabled', async () => { + const newProps = { + ...props, + openQueryBarMenu: true, + showQueryInput: true, + showFilterBar: true, + }; + const component = mount(wrapQueryBarMenuComponentInContext(newProps, 'kuery')); + const saveFilterSetButton = component.find( + '[data-test-subj="saved-query-management-save-button"]' + ); + const loadFilterSetButton = component.find( + '[data-test-subj="saved-query-management-load-button"]' + ); + expect(saveFilterSetButton.length).toBeTruthy(); + expect(saveFilterSetButton.first().prop('disabled')).toBe(true); + expect(loadFilterSetButton.length).toBeTruthy(); + expect(loadFilterSetButton.first().prop('disabled')).toBe(true); + }); + + it('should render the saved queries panels if the showFilterBar is true but disabled', async () => { + const newProps = { + ...props, + openQueryBarMenu: true, + showFilterBar: true, + }; + const component = mount(wrapQueryBarMenuComponentInContext(newProps, 'kuery')); + const applyToAllFiltersButton = component.find( + '[data-test-subj="filter-sets-applyToAllFilters"]' + ); + const removeAllFiltersButton = component.find( + '[data-test-subj="filter-sets-removeAllFilters"]' + ); + expect(applyToAllFiltersButton.length).toBeTruthy(); + expect(applyToAllFiltersButton.first().prop('disabled')).toBe(true); + expect(removeAllFiltersButton.length).toBeTruthy(); + expect(removeAllFiltersButton.first().prop('disabled')).toBe(true); + }); + + it('should enable the clear all button if query is given', async () => { + const newProps = { + ...props, + openQueryBarMenu: true, + showFilterBar: true, + query: { + query: 'category.keyword : "Men\'s Shoes" ', + language: 'kuery', + }, + }; + const component = mount(wrapQueryBarMenuComponentInContext(newProps, 'kuery')); + const removeAllFiltersButton = component.find( + '[data-test-subj="filter-sets-removeAllFilters"]' + ); + expect(removeAllFiltersButton.first().prop('disabled')).toBe(false); + }); + + it('should enable the apply to all button if filter is given', async () => { + const newProps = { + ...props, + openQueryBarMenu: true, + showFilterBar: true, + filters: filtersMock, + }; + const component = mount(wrapQueryBarMenuComponentInContext(newProps, 'kuery')); + const applyToAllFiltersButton = component.find( + '[data-test-subj="filter-sets-applyToAllFilters"]' + ); + expect(applyToAllFiltersButton.first().prop('disabled')).toBe(false); + }); + + it('should render the language switcher panel', async () => { + const newProps = { + ...props, + openQueryBarMenu: true, + showFilterBar: true, + showQueryInput: true, + }; + const component = mount(wrapQueryBarMenuComponentInContext(newProps, 'kuery')); + const languageSwitcher = component.find('[data-test-subj="switchQueryLanguageButton"]'); + expect(languageSwitcher.length).toBeTruthy(); + }); + + it('should render the save query quick buttons', async () => { + const newProps = { + ...props, + openQueryBarMenu: true, + showSaveQuery: true, + filters: filtersMock, + savedQuery: { + id: '8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a9', + attributes: { + title: 'Test', + description: '', + query: { + query: 'category.keyword : "Men\'s Shoes" ', + language: 'kuery', + }, + filters: [], + }, + }, + }; + const component = mount(wrapQueryBarMenuComponentInContext(newProps, 'kuery')); + const saveChangesButton = component.find( + '[data-test-subj="saved-query-management-save-changes-button"]' + ); + expect(saveChangesButton.length).toBeTruthy(); + const saveChangesAsNewButton = component.find( + '[data-test-subj="saved-query-management-save-as-new-button"]' + ); + expect(saveChangesAsNewButton.length).toBeTruthy(); + }); + + it('should render all filter panel options by default', async () => { + const newProps: QueryBarMenuProps = { + ...props, + openQueryBarMenu: true, + showFilterBar: true, + filters: filtersMock, + hiddenPanelOptions: undefined, + }; + const component = mount(wrapQueryBarMenuComponentInContext(newProps, 'kuery')); + + expect(component.find('[data-test-subj="filter-sets-removeAllFilters"]').length).toBeTruthy(); + component.find('[data-test-subj="filter-sets-applyToAllFilters"]').first().simulate('click'); + + await waitFor(() => { + component.update(); + expect(component.find('[data-test-subj="contextMenuPanelTitleButton"]').length).toBeTruthy(); + }); + + expect(component.find('[data-test-subj="filter-sets-pinAllFilters"]').length).toBeTruthy(); + expect(component.find('[data-test-subj="filter-sets-unpinAllFilters"]').length).toBeTruthy(); + expect(component.find('[data-test-subj="filter-sets-invertAllFilters"]').length).toBeTruthy(); + expect(component.find('[data-test-subj="filter-sets-disableAllFilters"]').length).toBeTruthy(); + expect(component.find('[data-test-subj="filter-sets-enableAllFilters"]').length).toBeTruthy(); + }); + + it('should hide pinning filter panel options', async () => { + const newProps: QueryBarMenuProps = { + ...props, + openQueryBarMenu: true, + showFilterBar: true, + filters: filtersMock, + hiddenPanelOptions: ['pinFilter'], + }; + const component = mount(wrapQueryBarMenuComponentInContext(newProps, 'kuery')); + + component.find('[data-test-subj="filter-sets-applyToAllFilters"]').first().simulate('click'); + + await waitFor(() => { + component.update(); + expect(component.find('[data-test-subj="contextMenuPanelTitleButton"]').length).toBeTruthy(); + }); + + expect(component.find('[data-test-subj="filter-sets-pinAllFilters"]').length).toBeFalsy(); + expect(component.find('[data-test-subj="filter-sets-unpinAllFilters"]').length).toBeFalsy(); + + expect(component.find('[data-test-subj="filter-sets-invertAllFilters"]').length).toBeTruthy(); + expect(component.find('[data-test-subj="filter-sets-disableAllFilters"]').length).toBeTruthy(); + expect(component.find('[data-test-subj="filter-sets-enableAllFilters"]').length).toBeTruthy(); + }); + + it('should hide negating and enabling filter panel options', async () => { + const newProps: QueryBarMenuProps = { + ...props, + openQueryBarMenu: true, + showFilterBar: true, + filters: filtersMock, + hiddenPanelOptions: ['negateFilter', 'disableFilter'], + }; + const component = mount(wrapQueryBarMenuComponentInContext(newProps, 'kuery')); + + component.find('[data-test-subj="filter-sets-applyToAllFilters"]').first().simulate('click'); + + await waitFor(() => { + component.update(); + expect(component.find('[data-test-subj="contextMenuPanelTitleButton"]').length).toBeTruthy(); + }); + + expect(component.find('[data-test-subj="filter-sets-pinAllFilters"]').length).toBeTruthy(); + expect(component.find('[data-test-subj="filter-sets-unpinAllFilters"]').length).toBeTruthy(); + expect(component.find('[data-test-subj="filter-sets-invertAllFilters"]').length).toBeFalsy(); + expect(component.find('[data-test-subj="filter-sets-disableAllFilters"]').length).toBeFalsy(); + expect(component.find('[data-test-subj="filter-sets-enableAllFilters"]').length).toBeFalsy(); + }); + + it('should hide deleting filter panel options', async () => { + const newProps: QueryBarMenuProps = { + ...props, + openQueryBarMenu: true, + showFilterBar: true, + filters: filtersMock, + hiddenPanelOptions: ['deleteFilter'], + }; + const component = mount(wrapQueryBarMenuComponentInContext(newProps, 'kuery')); + + expect(component.find('[data-test-subj="filter-sets-removeAllFilters"]').length).toBeFalsy(); + }); +}); diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx new file mode 100644 index 000000000000000..db6cbbe5385a683 --- /dev/null +++ b/src/plugins/unified_search/public/query_string_input/query_bar_menu.tsx @@ -0,0 +1,189 @@ +/* + * 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 React, { useState, useEffect } from 'react'; +import { + EuiButtonIcon, + EuiContextMenu, + EuiContextMenuPanel, + EuiPopover, + useGeneratedHtmlId, + EuiButtonIconProps, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { Filter, Query } from '@kbn/es-query'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import type { TimeRange, SavedQueryService, SavedQuery } from '@kbn/data-plugin/public'; +import { QueryBarMenuPanels, QueryBarMenuPanelsProps } from './query_bar_menu_panels'; +import { FilterEditorWrapper } from './filter_editor_wrapper'; + +export interface QueryBarMenuProps { + language: string; + onQueryChange: (payload: { dateRange: TimeRange; query?: Query }) => void; + onQueryBarSubmit: (payload: { dateRange: TimeRange; query?: Query }) => void; + toggleFilterBarMenuPopover: (value: boolean) => void; + openQueryBarMenu: boolean; + nonKqlMode?: 'lucene' | 'text'; + dateRangeFrom?: string; + dateRangeTo?: string; + savedQueryService: SavedQueryService; + saveAsNewQueryFormComponent?: JSX.Element; + saveFormComponent?: JSX.Element; + manageFilterSetComponent?: JSX.Element; + hiddenPanelOptions?: QueryBarMenuPanelsProps['hiddenPanelOptions']; + onFiltersUpdated?: (filters: Filter[]) => void; + filters?: Filter[]; + query?: Query; + savedQuery?: SavedQuery; + onClearSavedQuery?: () => void; + showQueryInput?: boolean; + showFilterBar?: boolean; + showSaveQuery?: boolean; + timeRangeForSuggestionsOverride?: boolean; + indexPatterns?: Array; + buttonProps?: Partial; +} + +export function QueryBarMenu({ + language, + nonKqlMode, + dateRangeFrom, + dateRangeTo, + onQueryChange, + onQueryBarSubmit, + savedQueryService, + saveAsNewQueryFormComponent, + saveFormComponent, + manageFilterSetComponent, + hiddenPanelOptions, + openQueryBarMenu, + toggleFilterBarMenuPopover, + onFiltersUpdated, + filters, + query, + savedQuery, + onClearSavedQuery, + showQueryInput, + showFilterBar, + showSaveQuery, + indexPatterns, + timeRangeForSuggestionsOverride, + buttonProps, +}: QueryBarMenuProps) { + const [renderedComponent, setRenderedComponent] = useState('menu'); + + useEffect(() => { + if (openQueryBarMenu) { + setRenderedComponent('menu'); + } + }, [openQueryBarMenu]); + + const normalContextMenuPopoverId = useGeneratedHtmlId({ + prefix: 'normalContextMenuPopover', + }); + const onButtonClick = () => { + toggleFilterBarMenuPopover(!openQueryBarMenu); + }; + + const closePopover = () => { + toggleFilterBarMenuPopover(false); + }; + + const buttonLabel = i18n.translate('unifiedSearch.filter.options.filterSetButtonLabel', { + defaultMessage: 'Saved query menu', + }); + + const button = ( + + + + ); + + const panels = QueryBarMenuPanels({ + filters, + savedQuery, + language, + dateRangeFrom, + dateRangeTo, + query, + showSaveQuery, + showFilterBar, + showQueryInput, + savedQueryService, + saveAsNewQueryFormComponent, + manageFilterSetComponent, + hiddenPanelOptions, + nonKqlMode, + closePopover, + onQueryBarSubmit, + onFiltersUpdated, + onClearSavedQuery, + onQueryChange, + setRenderedComponent, + }); + + const renderComponent = () => { + switch (renderedComponent) { + case 'menu': + default: + return ( + + ); + case 'saveForm': + return ( + {saveFormComponent}
    ]} /> + ); + case 'saveAsNewForm': + return ( + {saveAsNewQueryFormComponent}
    ]} + /> + ); + case 'addFilter': + return ( + , + ]} + /> + ); + } + }; + + return ( + <> + + {renderComponent()} + + + ); +} diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx new file mode 100644 index 000000000000000..9f8e70d6852a6cd --- /dev/null +++ b/src/plugins/unified_search/public/query_string_input/query_bar_menu_panels.tsx @@ -0,0 +1,528 @@ +/* + * 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 React, { useState, useRef, useEffect, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { isEqual } from 'lodash'; +import { + EuiContextMenuPanelDescriptor, + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiButton, +} from '@elastic/eui'; +import { + Filter, + Query, + enableFilter, + disableFilter, + toggleFilterNegated, + pinFilter, + unpinFilter, +} from '@kbn/es-query'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { KIBANA_USER_QUERY_LANGUAGE_KEY, UI_SETTINGS } from '@kbn/data-plugin/common'; +import type { + IDataPluginServices, + TimeRange, + SavedQueryService, + SavedQuery, +} from '@kbn/data-plugin/public'; +import { fromUser } from './from_user'; +import { QueryLanguageSwitcher } from './language_switcher'; +import { FilterPanelOption } from '../types'; + +const MAP_ITEMS_TO_FILTER_OPTION: Record = { + 'filter-sets-pinAllFilters': 'pinFilter', + 'filter-sets-unpinAllFilters': 'pinFilter', + 'filter-sets-enableAllFilters': 'disableFilter', + 'filter-sets-disableAllFilters': 'disableFilter', + 'filter-sets-invertAllFilters': 'negateFilter', + 'filter-sets-removeAllFilters': 'deleteFilter', +}; + +export interface QueryBarMenuPanelsProps { + filters?: Filter[]; + savedQuery?: SavedQuery; + language: string; + dateRangeFrom?: string; + dateRangeTo?: string; + query?: Query; + showSaveQuery?: boolean; + showQueryInput?: boolean; + showFilterBar?: boolean; + savedQueryService: SavedQueryService; + saveAsNewQueryFormComponent?: JSX.Element; + manageFilterSetComponent?: JSX.Element; + hiddenPanelOptions?: FilterPanelOption[]; + nonKqlMode?: 'lucene' | 'text'; + closePopover: () => void; + onQueryBarSubmit: (payload: { dateRange: TimeRange; query?: Query }) => void; + onFiltersUpdated?: (filters: Filter[]) => void; + onClearSavedQuery?: () => void; + onQueryChange: (payload: { dateRange: TimeRange; query?: Query }) => void; + setRenderedComponent: (component: string) => void; +} + +export function QueryBarMenuPanels({ + filters, + savedQuery, + language, + dateRangeFrom, + dateRangeTo, + query, + showSaveQuery, + showFilterBar, + showQueryInput, + savedQueryService, + saveAsNewQueryFormComponent, + manageFilterSetComponent, + hiddenPanelOptions, + nonKqlMode, + closePopover, + onQueryBarSubmit, + onFiltersUpdated, + onClearSavedQuery, + onQueryChange, + setRenderedComponent, +}: QueryBarMenuPanelsProps) { + const kibana = useKibana(); + const { appName, usageCollection, uiSettings, http, storage } = kibana.services; + const reportUiCounter = usageCollection?.reportUiCounter.bind(usageCollection, appName); + const cancelPendingListingRequest = useRef<() => void>(() => {}); + + const [savedQueries, setSavedQueries] = useState([] as SavedQuery[]); + const [hasFiltersOrQuery, setHasFiltersOrQuery] = useState(false); + const [savedQueryHasChanged, setSavedQueryHasChanged] = useState(false); + + useEffect(() => { + const fetchSavedQueries = async () => { + cancelPendingListingRequest.current(); + let requestGotCancelled = false; + cancelPendingListingRequest.current = () => { + requestGotCancelled = true; + }; + + const { queries: savedQueryItems } = await savedQueryService.findSavedQueries(''); + + if (requestGotCancelled) return; + + setSavedQueries(savedQueryItems.reverse().slice(0, 5)); + }; + if (showQueryInput && showFilterBar) { + fetchSavedQueries(); + } + }, [savedQueryService, savedQuery, showQueryInput, showFilterBar]); + + useEffect(() => { + if (savedQuery) { + let filtersHaveChanged = filters?.length !== savedQuery.attributes?.filters?.length; + if (filters?.length === savedQuery.attributes?.filters?.length) { + filtersHaveChanged = Boolean( + filters?.some( + (filter, index) => + !isEqual(filter.query, savedQuery.attributes?.filters?.[index]?.query) + ) + ); + } + if (filtersHaveChanged || !isEqual(query, savedQuery?.attributes.query)) { + setSavedQueryHasChanged(true); + } else { + setSavedQueryHasChanged(false); + } + } + }, [filters, query, savedQuery, savedQuery?.attributes.filters, savedQuery?.attributes.query]); + + useEffect(() => { + const hasFilters = Boolean(filters && filters.length > 0); + const hasQuery = Boolean(query && query.query); + setHasFiltersOrQuery(hasFilters || hasQuery); + }, [filters, onClearSavedQuery, query, savedQuery]); + + const getDateRange = () => { + const defaultTimeSetting = uiSettings!.get(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS); + return { + from: dateRangeFrom || defaultTimeSetting.from, + to: dateRangeTo || defaultTimeSetting.to, + }; + }; + + const handleSaveAsNew = useCallback(() => { + setRenderedComponent('saveAsNewForm'); + }, [setRenderedComponent]); + + const handleSave = useCallback(() => { + setRenderedComponent('saveForm'); + }, [setRenderedComponent]); + + const onEnableAll = () => { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:enable_all`); + const enabledFilters = filters?.map(enableFilter); + if (enabledFilters) { + onFiltersUpdated?.(enabledFilters); + } + }; + + const onDisableAll = () => { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:disable_all`); + const disabledFilters = filters?.map(disableFilter); + if (disabledFilters) { + onFiltersUpdated?.(disabledFilters); + } + }; + + const onToggleAllNegated = () => { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:invert_all`); + const negatedFilters = filters?.map(toggleFilterNegated); + if (negatedFilters) { + onFiltersUpdated?.(negatedFilters); + } + }; + + const onRemoveAll = () => { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:remove_all`); + onFiltersUpdated?.([]); + }; + + const onPinAll = () => { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:pin_all`); + const pinnedFilters = filters?.map(pinFilter); + if (pinnedFilters) { + onFiltersUpdated?.(pinnedFilters); + } + }; + + const onUnpinAll = () => { + reportUiCounter?.(METRIC_TYPE.CLICK, `filter:unpin_all`); + const unPinnedFilters = filters?.map(unpinFilter); + if (unPinnedFilters) { + onFiltersUpdated?.(unPinnedFilters); + } + }; + + const onQueryStringChange = (value: string) => { + onQueryChange({ + query: { query: value, language }, + dateRange: getDateRange(), + }); + }; + + const onSelectLanguage = (lang: string) => { + http.post('/api/kibana/kql_opt_in_stats', { + body: JSON.stringify({ opt_in: lang === 'kuery' }), + }); + + const storageKey = KIBANA_USER_QUERY_LANGUAGE_KEY; + storage.set(storageKey!, lang); + + const newQuery = { query: '', language: lang }; + onQueryStringChange(newQuery.query); + onQueryBarSubmit({ + query: { query: fromUser(newQuery.query), language: newQuery.language }, + dateRange: getDateRange(), + }); + }; + + const luceneLabel = i18n.translate('unifiedSearch.query.queryBar.luceneLanguageName', { + defaultMessage: 'Lucene', + }); + const kqlLabel = i18n.translate('unifiedSearch.query.queryBar.kqlLanguageName', { + defaultMessage: 'KQL', + }); + + const filtersRelatedPanels = [ + { + name: i18n.translate('unifiedSearch.filter.options.addFilterButtonLabel', { + defaultMessage: 'Add filter', + }), + icon: 'plus', + onClick: () => { + setRenderedComponent('addFilter'); + }, + }, + { + name: i18n.translate('unifiedSearch.filter.options.applyAllFiltersButtonLabel', { + defaultMessage: 'Apply to all', + }), + icon: 'filter', + panel: 2, + disabled: !Boolean(filters && filters.length > 0), + 'data-test-subj': 'filter-sets-applyToAllFilters', + }, + ]; + + const queryAndFiltersRelatedPanels = [ + { + name: savedQuery + ? i18n.translate('unifiedSearch.filter.options.loadOtherFilterSetLabel', { + defaultMessage: 'Load other saved query', + }) + : i18n.translate('unifiedSearch.filter.options.loadCurrentFilterSetLabel', { + defaultMessage: 'Load saved query', + }), + panel: 4, + width: 350, + icon: 'filter', + 'data-test-subj': 'saved-query-management-load-button', + disabled: !savedQueries.length, + }, + { + name: savedQuery + ? i18n.translate('unifiedSearch.filter.options.saveAsNewFilterSetLabel', { + defaultMessage: 'Save as new', + }) + : i18n.translate('unifiedSearch.filter.options.saveFilterSetLabel', { + defaultMessage: 'Save saved query', + }), + icon: 'save', + disabled: + !Boolean(showSaveQuery) || !hasFiltersOrQuery || (savedQuery && !savedQueryHasChanged), + panel: 1, + 'data-test-subj': 'saved-query-management-save-button', + }, + { isSeparator: true }, + ]; + + const items = []; + // apply to all actions are only shown when there are filters + if (showFilterBar) { + items.push(...filtersRelatedPanels); + } + // clear all actions are only shown when there are filters or query + if (showFilterBar || showQueryInput) { + items.push( + { + name: i18n.translate('unifiedSearch.filter.options.clearllFiltersButtonLabel', { + defaultMessage: 'Clear all', + }), + disabled: !hasFiltersOrQuery && !Boolean(savedQuery), + icon: 'crossInACircleFilled', + 'data-test-subj': 'filter-sets-removeAllFilters', + onClick: () => { + closePopover(); + onQueryBarSubmit({ + query: { query: '', language }, + dateRange: getDateRange(), + }); + onRemoveAll(); + onClearSavedQuery?.(); + }, + }, + { isSeparator: true } + ); + } + // saved queries actions are only shown when the showQueryInput and showFilterBar is true + if (showQueryInput && showFilterBar) { + items.push(...queryAndFiltersRelatedPanels); + } + + // language menu appears when the showQueryInput is true + if (showQueryInput) { + items.push({ + name: `Language: ${language === 'kuery' ? kqlLabel : luceneLabel}`, + panel: 3, + 'data-test-subj': 'switchQueryLanguageButton', + }); + } + + let panels = [ + { + id: 0, + title: ( + <> + + + + + {savedQuery + ? savedQuery.attributes.title + : i18n.translate('unifiedSearch.search.searchBar.savedQuery', { + defaultMessage: 'Saved query', + })} + + + + {savedQuery && savedQueryHasChanged && Boolean(showSaveQuery) && hasFiltersOrQuery && ( + + + + + {i18n.translate( + 'unifiedSearch.search.searchBar.savedQueryPopoverSaveChangesButtonText', + { + defaultMessage: 'Save changes', + } + )} + + + + + {i18n.translate( + 'unifiedSearch.search.searchBar.savedQueryPopoverSaveAsNewButtonText', + { + defaultMessage: 'Save as new', + } + )} + + + + + )} + + + ), + items, + }, + { + id: 1, + title: i18n.translate('unifiedSearch.filter.options.saveCurrentFilterSetLabel', { + defaultMessage: 'Save current saved query', + }), + disabled: !Boolean(showSaveQuery), + content:
    {saveAsNewQueryFormComponent}
    , + }, + { + id: 2, + initialFocusedItemIndex: 1, + title: i18n.translate('unifiedSearch.filter.options.applyAllFiltersButtonLabel', { + defaultMessage: 'Apply to all', + }), + items: [ + { + name: i18n.translate('unifiedSearch.filter.options.enableAllFiltersButtonLabel', { + defaultMessage: 'Enable all', + }), + icon: 'eye', + 'data-test-subj': 'filter-sets-enableAllFilters', + onClick: () => { + closePopover(); + onEnableAll(); + }, + }, + { + name: i18n.translate('unifiedSearch.filter.options.disableAllFiltersButtonLabel', { + defaultMessage: 'Disable all', + }), + 'data-test-subj': 'filter-sets-disableAllFilters', + icon: 'eyeClosed', + onClick: () => { + closePopover(); + onDisableAll(); + }, + }, + { + name: i18n.translate('unifiedSearch.filter.options.invertNegatedFiltersButtonLabel', { + defaultMessage: 'Invert inclusion', + }), + 'data-test-subj': 'filter-sets-invertAllFilters', + icon: 'invert', + onClick: () => { + closePopover(); + onToggleAllNegated(); + }, + }, + { + name: i18n.translate('unifiedSearch.filter.options.pinAllFiltersButtonLabel', { + defaultMessage: 'Pin all', + }), + 'data-test-subj': 'filter-sets-pinAllFilters', + icon: 'pin', + onClick: () => { + closePopover(); + onPinAll(); + }, + }, + { + name: i18n.translate('unifiedSearch.filter.options.unpinAllFiltersButtonLabel', { + defaultMessage: 'Unpin all', + }), + 'data-test-subj': 'filter-sets-unpinAllFilters', + icon: 'pin', + onClick: () => { + closePopover(); + onUnpinAll(); + }, + }, + ], + }, + { + id: 3, + title: i18n.translate('unifiedSearch.filter.options.filterLanguageLabel', { + defaultMessage: 'Filter language', + }), + content: ( + + ), + }, + { + id: 4, + title: i18n.translate('unifiedSearch.filter.options.loadCurrentFilterSetLabel', { + defaultMessage: 'Load saved query', + }), + width: 400, + content:
    {manageFilterSetComponent}
    , + }, + ] as EuiContextMenuPanelDescriptor[]; + + if (hiddenPanelOptions && hiddenPanelOptions.length > 0) { + panels = panels.map((panel) => ({ + ...panel, + items: panel.items?.filter((panelItem) => { + if (!panelItem['data-test-subj']) { + return true; + } + const panelFilterOption = MAP_ITEMS_TO_FILTER_OPTION[panelItem['data-test-subj']]; + if (!panelFilterOption) { + return true; + } + return !hiddenPanelOptions.includes(panelFilterOption); + }), + })); + } + + return panels; +} diff --git a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx index de1fa659aa13361..5427c61b485df7d 100644 --- a/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_bar_top_row.tsx @@ -11,6 +11,7 @@ import classNames from 'classnames'; import React, { useCallback, useMemo, useRef, useState } from 'react'; import deepEqual from 'fast-deep-equal'; import useObservable from 'react-use/lib/useObservable'; +import type { Filter } from '@kbn/es-query'; import { EMPTY } from 'rxjs'; import { map } from 'rxjs/operators'; import { @@ -20,8 +21,9 @@ import { EuiFieldText, usePrettyDuration, EuiIconProps, - EuiSuperUpdateButton, OnRefreshProps, + useIsWithinBreakpoints, + EuiSuperUpdateButton, } from '@elastic/eui'; import { IDataPluginServices, @@ -30,20 +32,22 @@ import { Query, getQueryLog, } from '@kbn/data-plugin/public'; +import { i18n } from '@kbn/i18n'; import { DataView } from '@kbn/data-views-plugin/public'; import type { PersistedLog } from '@kbn/data-plugin/public'; import { useKibana, withKibana } from '@kbn/kibana-react-plugin/public'; import { UI_SETTINGS } from '@kbn/data-plugin/common'; import QueryStringInputUI from './query_string_input'; import { NoDataPopover } from './no_data_popover'; -import { shallowEqual } from '../utils'; +import { shallowEqual } from '../utils/shallow_equal'; +import { AddFilterPopover } from './add_filter_popover'; +import { DataViewPicker, DataViewPickerProps } from '../dataview_picker'; +import { FilterButtonGroup } from '../filter_bar/filter_button_group/filter_button_group'; +import './query_bar.scss'; const SuperDatePicker = React.memo( EuiSuperDatePicker as any ) as unknown as typeof EuiSuperDatePicker; -const SuperUpdateButton = React.memo( - EuiSuperUpdateButton as any -) as unknown as typeof EuiSuperUpdateButton; const QueryStringInput = withKibana(QueryStringInputUI); @@ -63,7 +67,6 @@ export interface QueryBarTopRowProps { isLoading?: boolean; isRefreshPaused?: boolean; nonKqlMode?: 'lucene' | 'text'; - nonKqlModeHelpText?: string; onChange: (payload: { dateRange: TimeRange; query?: Query }) => void; onRefresh?: (payload: { dateRange: TimeRange }) => void; onRefreshChange?: (options: { isPaused: boolean; refreshInterval: number }) => void; @@ -74,10 +77,18 @@ export interface QueryBarTopRowProps { refreshInterval?: number; screenTitle?: string; showQueryInput?: boolean; + showAddFilter?: boolean; showDatePicker?: boolean; showAutoRefreshOnly?: boolean; timeHistory?: TimeHistoryContract; timeRangeForSuggestionsOverride?: boolean; + filters: Filter[]; + onFiltersUpdated?: (filters: Filter[]) => void; + dataViewPickerComponentProps?: DataViewPickerProps; + filterBar?: React.ReactNode; + showDatePickerAsBadge?: boolean; + showSubmitButton?: boolean; + isScreenshotMode?: boolean; } const SharingMetaFields = React.memo(function SharingMetaFields({ @@ -114,7 +125,13 @@ const SharingMetaFields = React.memo(function SharingMetaFields({ export const QueryBarTopRow = React.memo( function QueryBarTopRow(props: QueryBarTopRowProps) { - const { showQueryInput = true, showDatePicker = true, showAutoRefreshOnly = false } = props; + const isMobile = useIsWithinBreakpoints(['xs', 's']); + const { + showQueryInput = true, + showDatePicker = true, + showAutoRefreshOnly = false, + showSubmitButton = true, + } = props; const [isDateRangeInvalid, setIsDateRangeInvalid] = useState(false); const [isQueryInputFocused, setIsQueryInputFocused] = useState(false); @@ -283,14 +300,27 @@ export const QueryBarTopRow = React.memo( return Boolean(showDatePicker || showAutoRefreshOnly); } + function renderFilterMenuOnly(): boolean { + return !Boolean(props.showAddFilter) && Boolean(props.prepend); + } + + function shouldRenderUpdatebutton(): boolean { + return ( + Boolean(showSubmitButton) && + Boolean(showQueryInput || showDatePicker || showAutoRefreshOnly) + ); + } + + function shouldShowDatePickerAsBadge(): boolean { + return Boolean(props.showDatePickerAsBadge) && !shouldRenderQueryInput(); + } + function renderDatePicker() { if (!shouldRenderDatePicker()) { return null; } - const wrapperClasses = classNames('kbnQueryBar__datePickerWrapper', { - 'kbnQueryBar__datePickerWrapper-isHidden': isQueryInputFocused, - }); + const wrapperClasses = classNames('kbnQueryBar__datePickerWrapper'); return ( @@ -308,23 +338,52 @@ export const QueryBarTopRow = React.memo( dateFormat={uiSettings.get('dateFormat')} isAutoRefreshOnly={showAutoRefreshOnly} className="kbnQueryBar__datePicker" + isQuickSelectOnly={isMobile ? false : isQueryInputFocused} + width={isMobile ? 'full' : 'auto'} + compressed={shouldShowDatePickerAsBadge()} /> ); } function renderUpdateButton() { + if (!shouldRenderUpdatebutton()) { + return null; + } + + const buttonLabelUpdate = i18n.translate('unifiedSearch.queryBarTopRow.submitButton.update', { + defaultMessage: 'Needs updating', + }); + const buttonLabelRefresh = i18n.translate( + 'unifiedSearch.queryBarTopRow.submitButton.refresh', + { + defaultMessage: 'Refresh query', + } + ); + const button = props.customSubmitButton ? ( React.cloneElement(props.customSubmitButton, { onClick: onClickSubmitButton }) ) : ( - + + + ); if (!shouldRenderDatePicker()) { @@ -332,61 +391,124 @@ export const QueryBarTopRow = React.memo( } return ( - - - {renderDatePicker()} - {button} - - + + + + {renderDatePicker()} + {button} + + + ); } - function renderQueryInput() { - if (!shouldRenderQueryInput()) return; + function renderDataViewsPicker() { + if (!props.dataViewPickerComponentProps) return; return ( - - + ); } - const classes = classNames('kbnQueryBar', { - 'kbnQueryBar--withDatePicker': showDatePicker, - }); + function renderAddButton() { + return ( + Boolean(props.showAddFilter) && ( + + + + ) + ); + } + + function renderFilterButtonGroup() { + return ( + (Boolean(props.showAddFilter) || Boolean(props.prepend)) && ( + + + + ) + ); + } + + function renderQueryInput() { + return ( + + {!renderFilterMenuOnly() && renderFilterButtonGroup()} + {shouldRenderQueryInput() && ( + + + + )} + + ); + } + + const isScreenshotMode = props.isScreenshotMode === true; return ( - - {renderQueryInput()} + <> - {renderUpdateButton()} - + {!isScreenshotMode && ( + <> + + {renderDataViewsPicker()} + + {renderQueryInput()} + + {shouldShowDatePickerAsBadge() && props.filterBar} + {renderUpdateButton()} + + {!shouldShowDatePickerAsBadge() && props.filterBar} + + )} + ); }, ({ query: prevQuery, ...prevProps }, { query: nextQuery, ...nextProps }) => { diff --git a/src/plugins/unified_search/public/query_string_input/query_string_input.scss b/src/plugins/unified_search/public/query_string_input/query_string_input.scss new file mode 100644 index 000000000000000..f0a682564fa0e87 --- /dev/null +++ b/src/plugins/unified_search/public/query_string_input/query_string_input.scss @@ -0,0 +1,70 @@ +.kbnQueryBar__wrap { + width: 100%; + z-index: $euiZContentMenu; + height: $euiFormControlHeight; + display: flex; + + >[aria-expanded='true'] { + // Using filter allows it to adhere the children's bounds + filter: drop-shadow(0 5.7px 12px rgba($euiShadowColor, shadowOpacity(.05))); + } +} + +.kbnQueryBar__textareaWrap { + position: relative; + overflow: visible !important; // Override EUI form control + display: flex; + flex: 1 1 100%; +} + +.kbnQueryBar__textarea { + z-index: $euiZContentMenu; + resize: none !important; // When in the group, it will autosize + height: $euiFormControlHeight; + // Unlike most inputs within layout control groups, the text area still needs a border + // for multi-line content. These adjusts help it sit above the control groups + // shadow to line up correctly. + padding: ($euiSizeS + 2px) $euiSizeS $euiSizeS; + // Firefox adds margin to textarea + margin: 0; + + &:not(.kbnQueryBar__textarea--autoHeight) { + overflow-y: hidden; + overflow-x: hidden; + } + + // When focused, let it scroll + &.kbnQueryBar__textarea--autoHeight { + overflow-x: auto; + overflow-y: auto; + white-space: normal; + + } + + &.kbnQueryBar__textarea--isSuggestionsVisible { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + + &--isClearable { + @include euiFormControlWithIcon($isIconOptional: false, $side: 'right'); + } + + @include euiFormControlWithIcon($isIconOptional: true); + + ~.euiFormControlLayoutIcons { + // By default form control layout icon is vertically centered, but our textarea + // can expand to be multi-line, so we position it with padding that matches + // the parent textarea padding + z-index: $euiZContentMenu + 1; + top: $euiSizeM; + bottom: unset; + } + + &--withPrepend { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + margin-left: -1px; + width: calc(100% + 1px); + } +} \ No newline at end of file diff --git a/src/plugins/unified_search/public/query_string_input/query_string_input.test.tsx b/src/plugins/unified_search/public/query_string_input/query_string_input.test.tsx index b4eed13da7f5821..7437bf5fd4ece98 100644 --- a/src/plugins/unified_search/public/query_string_input/query_string_input.test.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_string_input.test.tsx @@ -110,7 +110,6 @@ describe('QueryStringInput', () => { ); await waitFor(() => getByText(kqlQuery.query)); - await waitFor(() => getByText('KQL')); }); it('Should pass the query language to the language switcher', () => { diff --git a/src/plugins/unified_search/public/query_string_input/query_string_input.tsx b/src/plugins/unified_search/public/query_string_input/query_string_input.tsx index a9f4127809ab702..dc606690fb7dd1a 100644 --- a/src/plugins/unified_search/public/query_string_input/query_string_input.tsx +++ b/src/plugins/unified_search/public/query_string_input/query_string_input.tsx @@ -24,9 +24,10 @@ import { EuiTextArea, htmlIdGenerator, PopoverAnchorPosition, + toSentenceCase, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { compact, debounce, isEqual, isFunction } from 'lodash'; +import { compact, debounce, isEmpty, isEqual, isFunction } from 'lodash'; import { Toast } from '@kbn/core/public'; import { IDataPluginServices, Query, getQueryLog } from '@kbn/data-plugin/public'; import { DataView } from '@kbn/data-views-plugin/public'; @@ -41,8 +42,10 @@ import { QueryLanguageSwitcher } from './language_switcher'; import type { SuggestionsListSize } from '../typeahead/suggestions_component'; import { SuggestionsComponent } from '../typeahead'; import { onRaf } from '../utils'; +import { FilterButtonGroup } from '../filter_bar/filter_button_group/filter_button_group'; import { QuerySuggestion, QuerySuggestionTypes } from '../autocomplete'; import { getTheme, getAutocomplete } from '../services'; +import './query_string_input.scss'; export interface QueryStringInputProps { indexPatterns: Array; @@ -72,7 +75,6 @@ export interface QueryStringInputProps { * this params add another option text, which is just a simple keyword search mode, the way a simple search box works */ nonKqlMode?: 'lucene' | 'text'; - nonKqlModeHelpText?: string; /** * @param autoSubmit if user selects a value, in that case kuery will be auto submitted */ @@ -124,6 +126,8 @@ const KEY_CODES = { export default class QueryStringInputUI extends PureComponent { static defaultProps = { storageKey: KIBANA_USER_QUERY_LANGUAGE_KEY, + iconType: 'search', + isClearable: true, }; public state: State = { @@ -678,31 +682,59 @@ export default class QueryStringInputUI extends PureComponent { this.handleAutoHeight(); }; + getSearchInputPlaceholder = () => { + let placeholder = ''; + if (!this.props.query.language || this.props.query.language === 'text') { + placeholder = i18n.translate('unifiedSearch.query.queryBar.searchInputPlaceholderForText', { + defaultMessage: 'Filter your data', + }); + } else { + const language = + this.props.query.language === 'kuery' ? 'KQL' : toSentenceCase(this.props.query.language); + + placeholder = i18n.translate('unifiedSearch.query.queryBar.searchInputPlaceholder', { + defaultMessage: 'Filter your data using {language} syntax', + values: { language }, + }); + } + + return placeholder; + }; + public render() { const isSuggestionsVisible = this.state.isSuggestionsVisible && { 'aria-controls': 'kbnTypeahead__items', 'aria-owns': 'kbnTypeahead__items', }; const ariaCombobox = { ...isSuggestionsVisible, role: 'combobox' }; - const containerClassName = classNames( - 'euiFormControlLayout euiFormControlLayout--group kbnQueryBar__wrap', - this.props.className - ); - const inputClassName = classNames( - 'kbnQueryBar__textarea', - this.props.iconType ? 'kbnQueryBar__textarea--withIcon' : null, - this.props.prepend ? 'kbnQueryBar__textarea--hasPrepend' : null, - !this.props.disableLanguageSwitcher ? 'kbnQueryBar__textarea--hasAppend' : null - ); - const inputWrapClassName = classNames( - 'euiFormControlLayout__childrenWrapper kbnQueryBar__textareaWrap', - this.props.prepend ? 'kbnQueryBar__textareaWrap--hasPrepend' : null, - !this.props.disableLanguageSwitcher ? 'kbnQueryBar__textareaWrap--hasAppend' : null + + const simpleLanguageSwitcher = this.props.disableLanguageSwitcher ? null : ( + ); + const prependElement = + this.props.prepend || simpleLanguageSwitcher ? ( + + ) : undefined; + + const containerClassName = classNames('kbnQueryBar__wrap', this.props.className); + const inputClassName = classNames('kbnQueryBar__textarea', { + 'kbnQueryBar__textarea--withIcon': this.props.iconType, + 'kbnQueryBar__textarea--isClearable': this.props.isClearable, + 'kbnQueryBar__textarea--withPrepend': prependElement, + 'kbnQueryBar__textarea--isSuggestionsVisible': + isSuggestionsVisible && !isEmpty(this.state.suggestions), + }); + const inputWrapClassName = classNames('kbnQueryBar__textareaWrap'); return (
    - {this.props.prepend} + {prependElement} +
    { >
    {
    - {this.props.disableLanguageSwitcher ? null : ( - - )}
    ); } diff --git a/src/plugins/unified_search/public/saved_query_form/save_query_form.tsx b/src/plugins/unified_search/public/saved_query_form/save_query_form.tsx index 53c5ec3310da25f..008c61b909ce129 100644 --- a/src/plugins/unified_search/public/saved_query_form/save_query_form.tsx +++ b/src/plugins/unified_search/public/saved_query_form/save_query_form.tsx @@ -7,20 +7,7 @@ */ import React, { useEffect, useState, useCallback } from 'react'; -import { - EuiButtonEmpty, - EuiModal, - EuiButton, - EuiModalHeader, - EuiModalHeaderTitle, - EuiModalBody, - EuiModalFooter, - EuiForm, - EuiFormRow, - EuiFieldText, - EuiSwitch, - EuiText, -} from '@elastic/eui'; +import { EuiButton, EuiForm, EuiFormRow, EuiFieldText, EuiSwitch } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { sortBy, isEqual } from 'lodash'; import { SavedQuery, SavedQueryService } from '@kbn/data-plugin/public'; @@ -51,8 +38,6 @@ export function SaveQueryForm({ showTimeFilterOption = true, }: Props) { const [title, setTitle] = useState(savedQuery?.attributes.title ?? ''); - const [enabledSaveButton, setEnabledSaveButton] = useState(Boolean(savedQuery)); - const [description, setDescription] = useState(savedQuery?.attributes.description ?? ''); const [savedQueries, setSavedQueries] = useState([]); const [shouldIncludeFilters, setShouldIncludeFilters] = useState( Boolean(savedQuery?.attributes.filters ?? true) @@ -72,10 +57,10 @@ export function SaveQueryForm({ } ); - const savedQueryDescriptionText = i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryDescriptionText', + const titleExistsErrorText = i18n.translate( + 'unifiedSearch.search.searchBar.savedQueryForm.titleExistsText', { - defaultMessage: 'Save query text and filters that you want to use again.', + defaultMessage: 'Name is required.', } ); @@ -98,36 +83,40 @@ export function SaveQueryForm({ errors.push(titleConflictErrorText); } + if (!title) { + errors.push(titleExistsErrorText); + } + if (!isEqual(errors, formErrors)) { setFormErrors(errors); return false; } return !formErrors.length; - }, [savedQueries, savedQuery, title, titleConflictErrorText, formErrors]); + }, [savedQueries, formErrors, title, savedQuery, titleConflictErrorText, titleExistsErrorText]); const onClickSave = useCallback(() => { if (validate()) { onSave({ id: savedQuery?.id, title, - description, + description: '', shouldIncludeFilters, shouldIncludeTimefilter, }); + onClose(); } }, [ validate, onSave, + onClose, savedQuery?.id, title, - description, shouldIncludeFilters, shouldIncludeTimefilter, ]); const onInputChange = useCallback((event) => { - setEnabledSaveButton(Boolean(event.target.value)); setFormErrors([]); setTitle(event.target.value); }, []); @@ -143,18 +132,16 @@ export function SaveQueryForm({ const saveQueryForm = ( - - {savedQueryDescriptionText} - - - { - setDescription(event.target.value); - }} - data-test-subj="saveQueryFormDescription" - /> - {showFilterOption && ( - + + )} - - ); - - return ( - - - - {i18n.translate('unifiedSearch.search.searchBar.savedQueryFormTitle', { - defaultMessage: 'Save query', - })} - - - - {saveQueryForm} - - - - {i18n.translate('unifiedSearch.search.searchBar.savedQueryFormCancelButtonText', { - defaultMessage: 'Cancel', - })} - + {i18n.translate('unifiedSearch.search.searchBar.savedQueryFormSaveButtonText', { - defaultMessage: 'Save', + defaultMessage: 'Save saved query', })} - - + + ); + + return <>{saveQueryForm}; } diff --git a/src/plugins/unified_search/public/saved_query_management/_index.scss b/src/plugins/unified_search/public/saved_query_management/_index.scss deleted file mode 100644 index 0580e857e8494b5..000000000000000 --- a/src/plugins/unified_search/public/saved_query_management/_index.scss +++ /dev/null @@ -1,2 +0,0 @@ -@import './saved_query_management_component'; -@import './saved_query_list_item'; diff --git a/src/plugins/unified_search/public/saved_query_management/_saved_query_list_item.scss b/src/plugins/unified_search/public/saved_query_management/_saved_query_list_item.scss deleted file mode 100644 index 714ba82dfb47642..000000000000000 --- a/src/plugins/unified_search/public/saved_query_management/_saved_query_list_item.scss +++ /dev/null @@ -1,21 +0,0 @@ -.kbnSavedQueryListItem { - margin-top: 0; - color: $euiLinkColor; -} - -// Can't actually target the button with classes, but styles to override -// are just user agent styles -.kbnSavedQueryListItem-selected button { - font-weight: $euiFontWeightBold; -} - -// This will ensure the info icon and tooltip shows even if the label gets truncated -.kbnSavedQueryListItem__label { - display: flex; - align-items: center; -} - -.kbnSavedQueryListItem__labelText { - @include euiTextTruncate; - margin-right: $euiSizeXS; -} diff --git a/src/plugins/unified_search/public/saved_query_management/index.ts b/src/plugins/unified_search/public/saved_query_management/index.ts index 4ead1907cd23bd9..134b24a4fb85c90 100644 --- a/src/plugins/unified_search/public/saved_query_management/index.ts +++ b/src/plugins/unified_search/public/saved_query_management/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { SavedQueryManagementComponent } from './saved_query_management_component'; +export { SavedQueryManagementList } from './saved_query_management_list'; diff --git a/src/plugins/unified_search/public/saved_query_management/saved_query_list_item.tsx b/src/plugins/unified_search/public/saved_query_management/saved_query_list_item.tsx deleted file mode 100644 index 71fbd8aad6e48f7..000000000000000 --- a/src/plugins/unified_search/public/saved_query_management/saved_query_list_item.tsx +++ /dev/null @@ -1,154 +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 { EuiListGroupItem, EuiConfirmModal, EuiIconTip } from '@elastic/eui'; - -import React, { Fragment, useState } from 'react'; -import classNames from 'classnames'; -import { i18n } from '@kbn/i18n'; -import { SavedQuery } from '@kbn/data-plugin/public'; - -interface Props { - savedQuery: SavedQuery; - isSelected: boolean; - showWriteOperations: boolean; - onSelect: (savedQuery: SavedQuery) => void; - onDelete: (savedQuery: SavedQuery) => void; -} - -export const SavedQueryListItem = ({ - savedQuery, - isSelected, - onSelect, - onDelete, - showWriteOperations, -}: Props) => { - const [showDeletionConfirmationModal, setShowDeletionConfirmationModal] = useState(false); - - const selectButtonAriaLabelText = isSelected - ? i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryPopoverSavedQueryListItemSelectedButtonAriaLabel', - { - defaultMessage: - 'Saved query button selected {savedQueryName}. Press to clear any changes.', - values: { savedQueryName: savedQuery.attributes.title }, - } - ) - : i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryPopoverSavedQueryListItemButtonAriaLabel', - { - defaultMessage: 'Saved query button {savedQueryName}', - values: { savedQueryName: savedQuery.attributes.title }, - } - ); - - const selectButtonDataTestSubj = isSelected - ? `load-saved-query-${savedQuery.attributes.title}-button saved-query-list-item-selected` - : `load-saved-query-${savedQuery.attributes.title}-button`; - - const classes = classNames('kbnSavedQueryListItem', { - 'kbnSavedQueryListItem-selected': isSelected, - }); - - const label = ( - - {savedQuery.attributes.title}{' '} - {savedQuery.attributes.description && ( - - )} - - ); - - return ( - - { - onSelect(savedQuery); - }} - aria-label={selectButtonAriaLabelText} - label={label} - iconType={isSelected ? 'check' : undefined} - extraAction={ - showWriteOperations - ? { - color: 'danger', - onClick: () => setShowDeletionConfirmationModal(true), - iconType: 'trash', - iconSize: 's', - 'aria-label': i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryPopoverDeleteButtonAriaLabel', - { - defaultMessage: 'Delete saved query {savedQueryName}', - values: { savedQueryName: savedQuery.attributes.title }, - } - ), - title: i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryPopoverDeleteButtonAriaLabel', - { - defaultMessage: 'Delete saved query {savedQueryName}', - values: { savedQueryName: savedQuery.attributes.title }, - } - ), - 'data-test-subj': `delete-saved-query-${savedQuery.attributes.title}-button`, - } - : undefined - } - /> - - {showDeletionConfirmationModal && ( - { - onDelete(savedQuery); - setShowDeletionConfirmationModal(false); - }} - buttonColor="danger" - onCancel={() => { - setShowDeletionConfirmationModal(false); - }} - /> - )} - - ); -}; diff --git a/src/plugins/unified_search/public/saved_query_management/saved_query_management_component.tsx b/src/plugins/unified_search/public/saved_query_management/saved_query_management_component.tsx deleted file mode 100644 index 07d3a9d799a66da..000000000000000 --- a/src/plugins/unified_search/public/saved_query_management/saved_query_management_component.tsx +++ /dev/null @@ -1,340 +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 { - EuiPopover, - EuiPopoverTitle, - EuiPopoverFooter, - EuiButtonEmpty, - EuiButton, - EuiFlexGroup, - EuiFlexItem, - EuiListGroup, - EuiPagination, - EuiText, - EuiSpacer, - EuiIcon, -} from '@elastic/eui'; - -import { i18n } from '@kbn/i18n'; -import React, { useCallback, useEffect, useState, Fragment, useRef } from 'react'; -import { sortBy } from 'lodash'; -import { SavedQuery, SavedQueryService } from '@kbn/data-plugin/public'; -import { SavedQueryListItem } from './saved_query_list_item'; - -const perPage = 50; -interface Props { - showSaveQuery?: boolean; - loadedSavedQuery?: SavedQuery; - savedQueryService: SavedQueryService; - onSave: () => void; - onSaveAsNew: () => void; - onLoad: (savedQuery: SavedQuery) => void; - onClearSavedQuery: () => void; -} - -export function SavedQueryManagementComponent({ - showSaveQuery, - loadedSavedQuery, - onSave, - onSaveAsNew, - onLoad, - onClearSavedQuery, - savedQueryService, -}: Props) { - const [isOpen, setIsOpen] = useState(false); - const [savedQueries, setSavedQueries] = useState([] as SavedQuery[]); - const [count, setTotalCount] = useState(0); - const [activePage, setActivePage] = useState(0); - const cancelPendingListingRequest = useRef<() => void>(() => {}); - - useEffect(() => { - const fetchCountAndSavedQueries = async () => { - cancelPendingListingRequest.current(); - let requestGotCancelled = false; - cancelPendingListingRequest.current = () => { - requestGotCancelled = true; - }; - - const { total: savedQueryCount, queries: savedQueryItems } = - await savedQueryService.findSavedQueries('', perPage, activePage + 1); - - if (requestGotCancelled) return; - - const sortedSavedQueryItems = sortBy(savedQueryItems, 'attributes.title'); - setTotalCount(savedQueryCount); - setSavedQueries(sortedSavedQueryItems); - }; - if (isOpen) { - fetchCountAndSavedQueries(); - } - }, [isOpen, activePage, savedQueryService]); - - const handleTogglePopover = useCallback( - () => setIsOpen((currentState) => !currentState), - [setIsOpen] - ); - - const handleClosePopover = useCallback(() => setIsOpen(false), []); - - const handleSave = useCallback(() => { - handleClosePopover(); - onSave(); - }, [handleClosePopover, onSave]); - - const handleSaveAsNew = useCallback(() => { - handleClosePopover(); - onSaveAsNew(); - }, [handleClosePopover, onSaveAsNew]); - - const handleSelect = useCallback( - (savedQueryToSelect) => { - handleClosePopover(); - onLoad(savedQueryToSelect); - }, - [handleClosePopover, onLoad] - ); - - const handleDelete = useCallback( - (savedQueryToDelete: SavedQuery) => { - const onDeleteSavedQuery = async (savedQuery: SavedQuery) => { - cancelPendingListingRequest.current(); - setSavedQueries( - savedQueries.filter((currentSavedQuery) => currentSavedQuery.id !== savedQuery.id) - ); - - if (loadedSavedQuery && loadedSavedQuery.id === savedQuery.id) { - onClearSavedQuery(); - } - - await savedQueryService.deleteSavedQuery(savedQuery.id); - setActivePage(0); - }; - - onDeleteSavedQuery(savedQueryToDelete); - handleClosePopover(); - }, - [handleClosePopover, loadedSavedQuery, onClearSavedQuery, savedQueries, savedQueryService] - ); - - const savedQueryDescriptionText = i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryDescriptionText', - { - defaultMessage: 'Save query text and filters that you want to use again.', - } - ); - - const noSavedQueriesDescriptionText = - i18n.translate('unifiedSearch.search.searchBar.savedQueryNoSavedQueriesText', { - defaultMessage: 'There are no saved queries.', - }) + - ' ' + - savedQueryDescriptionText; - - const savedQueryPopoverTitleText = i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryPopoverTitleText', - { - defaultMessage: 'Saved Queries', - } - ); - - const goToPage = (pageNumber: number) => { - setActivePage(pageNumber); - }; - - const savedQueryPopoverButton = ( - - - - - ); - - const savedQueryRows = () => { - const savedQueriesWithoutCurrent = savedQueries.filter((savedQuery) => { - if (!loadedSavedQuery) return true; - return savedQuery.id !== loadedSavedQuery.id; - }); - const savedQueriesReordered = - loadedSavedQuery && savedQueriesWithoutCurrent.length !== savedQueries.length - ? [loadedSavedQuery, ...savedQueriesWithoutCurrent] - : [...savedQueriesWithoutCurrent]; - return savedQueriesReordered.map((savedQuery) => ( - - )); - }; - - return ( - - -
    - - {savedQueryPopoverTitleText} - - {savedQueries.length > 0 ? ( - - -

    {savedQueryDescriptionText}

    -
    -
    - - {savedQueryRows()} - -
    - -
    - ) : ( - - -

    {noSavedQueriesDescriptionText}

    -
    - -
    - )} - - - {showSaveQuery && loadedSavedQuery && ( - - - - {i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryPopoverSaveChangesButtonText', - { - defaultMessage: 'Save changes', - } - )} - - - - - {i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryPopoverSaveAsNewButtonText', - { - defaultMessage: 'Save as new', - } - )} - - - - )} - {showSaveQuery && !loadedSavedQuery && ( - - - {i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryPopoverSaveButtonText', - { - defaultMessage: 'Save current query', - } - )} - - - )} - - - {loadedSavedQuery && ( - - {i18n.translate( - 'unifiedSearch.search.searchBar.savedQueryPopoverClearButtonText', - { - defaultMessage: 'Clear', - } - )} - - )} - - - -
    -
    -
    - ); -} diff --git a/src/plugins/unified_search/public/saved_query_management/_saved_query_management_component.scss b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.scss similarity index 76% rename from src/plugins/unified_search/public/saved_query_management/_saved_query_management_component.scss rename to src/plugins/unified_search/public/saved_query_management/saved_query_management_list.scss index 928cb5a34d6deb5..7ce304310ae56a1 100644 --- a/src/plugins/unified_search/public/saved_query_management/_saved_query_management_component.scss +++ b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.scss @@ -1,18 +1,9 @@ -.kbnSavedQueryManagement__popover { - max-width: $euiFormMaxWidth; -} - .kbnSavedQueryManagement__listWrapper { // Addition height will ensure one item is "cutoff" to indicate more below the scroll max-height: $euiFormMaxWidth + $euiSize; overflow-y: hidden; } -.kbnSavedQueryManagement__pagination { - justify-content: center; - padding: ($euiSizeM / 2) $euiSizeM $euiSizeM; -} - .kbnSavedQueryManagement__text { padding: $euiSizeM $euiSizeM ($euiSizeM / 2) $euiSizeM; } diff --git a/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.test.tsx b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.test.tsx new file mode 100644 index 000000000000000..c7db17ea934d5ec --- /dev/null +++ b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.test.tsx @@ -0,0 +1,165 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { EuiSelectable } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n-react'; +import { act } from 'react-dom/test-utils'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { mountWithIntl as mount } from '@kbn/test-jest-helpers'; +import { ReactWrapper } from 'enzyme'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { coreMock, applicationServiceMock } from '@kbn/core/public/mocks'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { + SavedQueryManagementListProps, + SavedQueryManagementList, +} from './saved_query_management_list'; + +describe('Saved query management list component', () => { + const startMock = coreMock.createStart(); + const dataMock = dataPluginMock.createStartContract(); + const applicationMock = applicationServiceMock.createStartContract(); + const application = { + ...applicationMock, + capabilities: { + ...applicationMock.capabilities, + savedObjectsManagement: { edit: true }, + }, + }; + function wrapSavedQueriesListComponentInContext(testProps: SavedQueryManagementListProps) { + const services = { + uiSettings: startMock.uiSettings, + http: startMock.http, + application, + }; + + return ( + + + + + + ); + } + + function flushEffect(component: ReactWrapper) { + return act(async () => { + await component; + await new Promise((r) => setImmediate(r)); + component.update(); + }); + } + let props: SavedQueryManagementListProps; + beforeEach(() => { + props = { + onLoad: jest.fn(), + onClearSavedQuery: jest.fn(), + onClose: jest.fn(), + showSaveQuery: true, + hasFiltersOrQuery: false, + savedQueryService: { + ...dataMock.query.savedQueries, + findSavedQueries: jest.fn().mockResolvedValue({ + queries: [ + { + id: '8a0b7cd0-b0c4-11ec-92b2-73d62e0d28a9', + attributes: { + title: 'Test', + description: '', + query: { + query: 'category.keyword : "Men\'s Shoes" ', + language: 'kuery', + }, + filters: [], + }, + }, + ], + }), + deleteSavedQuery: jest.fn(), + }, + }; + }); + it('should render the list component if saved queries exist', async () => { + const component = mount(wrapSavedQueriesListComponentInContext(props)); + await flushEffect(component); + expect(component.find('[data-test-subj="saved-query-management-list"]').length).toBe(1); + }); + + it('should not rendet the list component if not saved queries exist', async () => { + const newProps = { + ...props, + savedQueryService: { + ...dataMock.query.savedQueries, + findSavedQueries: jest.fn().mockResolvedValue({ + queries: [], + }), + }, + }; + const component = mount(wrapSavedQueriesListComponentInContext(newProps)); + await flushEffect(component); + expect(component.find('[data-test-subj="saved-query-management-empty"]').length).toBeTruthy(); + }); + + it('should render the saved queries on the selectable component', async () => { + const component = mount(wrapSavedQueriesListComponentInContext(props)); + await flushEffect(component); + expect(component.find(EuiSelectable).prop('options').length).toBe(1); + expect(component.find(EuiSelectable).prop('options')[0].label).toBe('Test'); + }); + + it('should call the onLoad function', async () => { + const onLoadSpy = jest.fn(); + const newProps = { + ...props, + onLoad: onLoadSpy, + }; + const component = mount(wrapSavedQueriesListComponentInContext(newProps)); + await flushEffect(component); + component.find('[data-test-subj="load-saved-query-Test-button"]').first().simulate('click'); + expect( + component.find('[data-test-subj="saved-query-management-apply-changes-button"]').length + ).toBeTruthy(); + component + .find('[data-test-subj="saved-query-management-apply-changes-button"]') + .first() + .simulate('click'); + expect(onLoadSpy).toBeCalled(); + }); + + it('should render the button with the correct text', async () => { + const component = mount(wrapSavedQueriesListComponentInContext(props)); + await flushEffect(component); + expect( + component + .find('[data-test-subj="saved-query-management-apply-changes-button"]') + .first() + .text() + ).toBe('Apply saved query'); + + const newProps = { + ...props, + hasFiltersOrQuery: true, + }; + const updatedComponent = mount(wrapSavedQueriesListComponentInContext(newProps)); + await flushEffect(component); + expect( + updatedComponent + .find('[data-test-subj="saved-query-management-apply-changes-button"]') + .first() + .text() + ).toBe('Replace with selected saved query'); + }); + + it('should render the modal on delete', async () => { + const component = mount(wrapSavedQueriesListComponentInContext(props)); + await flushEffect(component); + findTestSubject(component, 'delete-saved-query-Test-button').simulate('click'); + expect(component.find('[data-test-subj="confirmModalConfirmButton"]').length).toBeTruthy(); + }); +}); diff --git a/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.tsx b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.tsx new file mode 100644 index 000000000000000..8f9a159c466c063 --- /dev/null +++ b/src/plugins/unified_search/public/saved_query_management/saved_query_management_list.tsx @@ -0,0 +1,386 @@ +/* + * 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 { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPanel, + EuiSelectable, + EuiText, + EuiPopoverFooter, + EuiButtonIcon, + EuiButtonEmpty, + EuiConfirmModal, + usePrettyDuration, + ShortDate, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useEffect, useState, useRef } from 'react'; +import { css } from '@emotion/react'; +import { sortBy } from 'lodash'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { IDataPluginServices, SavedQuery, SavedQueryService } from '@kbn/data-plugin/public'; +import type { SavedQueryAttributes } from '@kbn/data-plugin/common'; +import './saved_query_management_list.scss'; + +export interface SavedQueryManagementListProps { + showSaveQuery?: boolean; + loadedSavedQuery?: SavedQuery; + savedQueryService: SavedQueryService; + onLoad: (savedQuery: SavedQuery) => void; + onClearSavedQuery: () => void; + onClose: () => void; + hasFiltersOrQuery: boolean; +} + +interface SelectableProps { + key?: string; + label: string; + value?: string; + checked?: 'on' | 'off' | undefined; +} + +interface DurationRange { + end: ShortDate; + label?: string; + start: ShortDate; +} + +const commonDurationRanges: DurationRange[] = [ + { start: 'now/d', end: 'now/d', label: 'Today' }, + { start: 'now/w', end: 'now/w', label: 'This week' }, + { start: 'now/M', end: 'now/M', label: 'This month' }, + { start: 'now/y', end: 'now/y', label: 'This year' }, + { start: 'now-1d/d', end: 'now-1d/d', label: 'Yesterday' }, + { start: 'now/w', end: 'now', label: 'Week to date' }, + { start: 'now/M', end: 'now', label: 'Month to date' }, + { start: 'now/y', end: 'now', label: 'Year to date' }, +]; + +const itemTitle = (attributes: SavedQueryAttributes, format: string) => { + let label = attributes.title; + const prettifier = usePrettyDuration; + + if (attributes.description) { + label += `; ${attributes.description}`; + } + + if (attributes.timefilter) { + label += `; ${prettifier({ + timeFrom: attributes.timefilter?.from, + timeTo: attributes.timefilter?.to, + quickRanges: commonDurationRanges, + dateFormat: format, + })}`; + } + + return label; +}; + +const itemLabel = (attributes: SavedQueryAttributes) => { + let label: React.ReactNode = attributes.title; + + if (attributes.description) { + label = ( + <> + {label} + + ); + } + + if (attributes.timefilter) { + label = ( + <> + {label} + + ); + } + + return label; +}; + +export function SavedQueryManagementList({ + showSaveQuery, + loadedSavedQuery, + onLoad, + onClearSavedQuery, + savedQueryService, + onClose, + hasFiltersOrQuery, +}: SavedQueryManagementListProps) { + const kibana = useKibana(); + const [savedQueries, setSavedQueries] = useState([] as SavedQuery[]); + const [selectedSavedQuery, setSelectedSavedQuery] = useState(null as SavedQuery | null); + const [toBeDeletedSavedQuery, setToBeDeletedSavedQuery] = useState(null as SavedQuery | null); + const [showDeletionConfirmationModal, setShowDeletionConfirmationModal] = useState(false); + const cancelPendingListingRequest = useRef<() => void>(() => {}); + const { uiSettings, http, application } = kibana.services; + const format = uiSettings.get('dateFormat'); + + useEffect(() => { + const fetchCountAndSavedQueries = async () => { + cancelPendingListingRequest.current(); + let requestGotCancelled = false; + cancelPendingListingRequest.current = () => { + requestGotCancelled = true; + }; + + const { queries: savedQueryItems } = await savedQueryService.findSavedQueries(); + + if (requestGotCancelled) return; + + const sortedSavedQueryItems = sortBy(savedQueryItems, 'attributes.title'); + setSavedQueries(sortedSavedQueryItems); + }; + fetchCountAndSavedQueries(); + }, [savedQueryService]); + + const handleLoad = useCallback(() => { + if (selectedSavedQuery) { + onLoad(selectedSavedQuery); + onClose(); + } + }, [onLoad, selectedSavedQuery, onClose]); + + const handleSelect = useCallback((savedQueryToSelect) => { + setSelectedSavedQuery(savedQueryToSelect); + }, []); + + const handleDelete = useCallback((savedQueryToDelete: SavedQuery) => { + setShowDeletionConfirmationModal(true); + setToBeDeletedSavedQuery(savedQueryToDelete); + }, []); + + const onDelete = useCallback( + (savedQueryToDelete: string) => { + const onDeleteSavedQuery = async (savedQueryId: string) => { + cancelPendingListingRequest.current(); + setSavedQueries( + savedQueries.filter((currentSavedQuery) => currentSavedQuery.id !== savedQueryId) + ); + + if (loadedSavedQuery && loadedSavedQuery.id === savedQueryId) { + onClearSavedQuery(); + } + + await savedQueryService.deleteSavedQuery(savedQueryId); + }; + + onDeleteSavedQuery(savedQueryToDelete); + }, + [loadedSavedQuery, onClearSavedQuery, savedQueries, savedQueryService] + ); + + const savedQueryDescriptionText = i18n.translate( + 'unifiedSearch.search.searchBar.savedQueryDescriptionText', + { + defaultMessage: 'Save query text and filters that you want to use again.', + } + ); + + const noSavedQueriesDescriptionText = + i18n.translate('unifiedSearch.search.searchBar.savedQueryNoSavedQueriesText', { + defaultMessage: 'No saved queries.', + }) + + ' ' + + savedQueryDescriptionText; + + const savedQueriesOptions = () => { + const savedQueriesWithoutCurrent = savedQueries.filter((savedQuery) => { + if (!loadedSavedQuery) return true; + return savedQuery.id !== loadedSavedQuery.id; + }); + const savedQueriesReordered = + loadedSavedQuery && savedQueriesWithoutCurrent.length !== savedQueries.length + ? [loadedSavedQuery, ...savedQueriesWithoutCurrent] + : [...savedQueriesWithoutCurrent]; + + return savedQueriesReordered.map((savedQuery) => { + return { + key: savedQuery.id, + label: itemLabel(savedQuery.attributes), + title: itemTitle(savedQuery.attributes, format), + 'data-test-subj': `load-saved-query-${savedQuery.attributes.title}-button`, + value: savedQuery.id, + checked: + (loadedSavedQuery && savedQuery.id === loadedSavedQuery.id) || + (selectedSavedQuery && savedQuery.id === selectedSavedQuery.id) + ? 'on' + : undefined, + append: !!showSaveQuery && ( + handleDelete(savedQuery)} + color="danger" + /> + ), + }; + }) as unknown as SelectableProps[]; + }; + + const canEditSavedObjects = application.capabilities.savedObjectsManagement.edit; + + const listComponent = ( + <> + {savedQueries.length > 0 ? ( + <> +
    + + aria-label="Basic example" + options={savedQueriesOptions()} + searchable + singleSelection="always" + onChange={(choices) => { + const choice = choices.find(({ checked }) => checked) as unknown as { + value: string; + }; + if (choice) { + handleSelect(savedQueries.find((savedQuery) => savedQuery.id === choice.value)); + } + }} + searchProps={{ + compressed: true, + placeholder: i18n.translate( + 'unifiedSearch.query.queryBar.indexPattern.findFilterSet', + { + defaultMessage: 'Find a saved query', + } + ), + }} + listProps={{ + isVirtualized: true, + }} + > + {(list, search) => ( + <> + + {search} + + {list} + + )} + +
    + + ) : ( + <> + +

    {noSavedQueriesDescriptionText}

    +
    + + )} + + + {canEditSavedObjects && ( + + + Manage + + + )} + + + {hasFiltersOrQuery + ? i18n.translate( + 'unifiedSearch.search.searchBar.savedQueryPopoverReplaceFilterSetLabel', + { + defaultMessage: 'Replace with selected saved query', + } + ) + : i18n.translate( + 'unifiedSearch.search.searchBar.savedQueryPopoverApplyFilterSetLabel', + { + defaultMessage: 'Apply saved query', + } + )} + + + + + {showDeletionConfirmationModal && toBeDeletedSavedQuery && ( + { + onDelete(toBeDeletedSavedQuery.id); + setShowDeletionConfirmationModal(false); + }} + buttonColor="danger" + onCancel={() => { + setShowDeletionConfirmationModal(false); + }} + /> + )} + + ); + + return listComponent; +} diff --git a/src/plugins/unified_search/public/search_bar/create_search_bar.tsx b/src/plugins/unified_search/public/search_bar/create_search_bar.tsx index 3d8aa26af22af46..c73aa258863ed3a 100644 --- a/src/plugins/unified_search/public/search_bar/create_search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/create_search_bar.tsx @@ -26,6 +26,7 @@ interface StatefulSearchBarDeps { data: Omit; storage: IStorageWrapper; usageCollection?: UsageCollectionSetup; + isScreenshotMode?: boolean; } export type StatefulSearchBarProps = SearchBarOwnProps & { @@ -110,7 +111,13 @@ const overrideDefaultBehaviors = (props: StatefulSearchBarProps) => { return props.useDefaultBehaviors ? {} : props; }; -export function createSearchBar({ core, storage, data, usageCollection }: StatefulSearchBarDeps) { +export function createSearchBar({ + core, + storage, + data, + usageCollection, + isScreenshotMode = false, +}: StatefulSearchBarDeps) { // App name should come from the core application service. // Until it's available, we'll ask the user to provide it for the pre-wired component. return (props: StatefulSearchBarProps) => { @@ -191,11 +198,13 @@ export function createSearchBar({ core, storage, data, usageCollection }: Statef onSaved={defaultOnSavedQueryUpdated(props, setSavedQuery)} iconType={props.iconType} nonKqlMode={props.nonKqlMode} - nonKqlModeHelpText={props.nonKqlModeHelpText} customSubmitButton={props.customSubmitButton} isClearable={props.isClearable} placeholder={props.placeholder} {...overrideDefaultBehaviors(props)} + dataViewPickerComponentProps={props.dataViewPickerComponentProps} + displayStyle={props.displayStyle} + isScreenshotMode={isScreenshotMode} /> ); diff --git a/src/plugins/unified_search/public/search_bar/search_bar.styles.ts b/src/plugins/unified_search/public/search_bar/search_bar.styles.ts new file mode 100644 index 000000000000000..36d06d1cb9c7f76 --- /dev/null +++ b/src/plugins/unified_search/public/search_bar/search_bar.styles.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { UseEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; + +export const searchBarStyles = ({ euiTheme }: UseEuiTheme) => { + return { + uniSearchBar: css` + padding: ${euiTheme.size.s}; + `, + detached: css` + border-bottom: ${euiTheme.border.thin}; + `, + inPage: css` + padding: 0; + `, + hidden: css` + display: none; + `, + }; +}; diff --git a/src/plugins/unified_search/public/search_bar/search_bar.test.tsx b/src/plugins/unified_search/public/search_bar/search_bar.test.tsx index 14310b69809e07c..fe5e03ab7fb373f 100644 --- a/src/plugins/unified_search/public/search_bar/search_bar.test.tsx +++ b/src/plugins/unified_search/public/search_bar/search_bar.test.tsx @@ -16,24 +16,21 @@ import { coreMock } from '@kbn/core/public/mocks'; const startMock = coreMock.createStart(); import { mount } from 'enzyme'; -import { IIndexPattern } from '@kbn/data-plugin/public'; +import { DataView } from '@kbn/data-views-plugin/public'; +import { EuiThemeProvider } from '@elastic/eui'; const mockTimeHistory = { get: () => { return []; }, + add: jest.fn(), + get$: () => { + return { + pipe: () => {}, + }; + }, }; -jest.mock('../filter_bar', () => { - return { - FilterBar: () =>
    , - }; -}); - -jest.mock('../query_string_input/query_bar_top_row', () => { - return () =>
    ; -}); - const noop = jest.fn(); const createMockWebStorage = () => ({ @@ -66,7 +63,7 @@ const mockIndexPattern = { searchable: true, }, ], -} as IIndexPattern; +} as DataView; const kqlQuery = { query: 'response:200', @@ -88,24 +85,45 @@ function wrapSearchBarInContext(testProps: any) { storage: createMockStorage(), data: { query: { - savedQueries: {}, + savedQueries: { + findSavedQueries: () => + Promise.resolve({ + queries: [ + { + id: 'testwewe', + attributes: { + title: 'Saved query 1', + description: '', + query: { + query: 'category.keyword : "Men\'s Shoes" ', + language: 'kuery', + }, + filters: [], + }, + }, + ], + }), + }, }, }, }; return ( - - - - - + + + + + + + ); } describe('SearchBar', () => { - const SEARCH_BAR_ROOT = '.globalQueryBar'; - const FILTER_BAR = '.filterBar'; - const QUERY_BAR = '.queryBar'; + const SEARCH_BAR_ROOT = '.uniSearchBar'; + const FILTER_BAR = '[data-test-subj="unifiedFilterBar"]'; + const QUERY_BAR = '.kbnQueryBar'; + const QUERY_INPUT = '[data-test-subj="unifiedQueryInput"]'; beforeEach(() => { jest.clearAllMocks(); @@ -118,22 +136,9 @@ describe('SearchBar', () => { }) ); - expect(component.find(SEARCH_BAR_ROOT).length).toBe(1); - expect(component.find(FILTER_BAR).length).toBe(0); - expect(component.find(QUERY_BAR).length).toBe(1); - }); - - it('Should render empty when timepicker is off and no options provided', () => { - const component = mount( - wrapSearchBarInContext({ - indexPatterns: [mockIndexPattern], - showDatePicker: false, - }) - ); - - expect(component.find(SEARCH_BAR_ROOT).length).toBe(1); - expect(component.find(FILTER_BAR).length).toBe(0); - expect(component.find(QUERY_BAR).length).toBe(0); + expect(component.find(SEARCH_BAR_ROOT)).toBeTruthy(); + expect(component.find(FILTER_BAR).length).toBeFalsy(); + expect(component.find(QUERY_BAR).length).toBeTruthy(); }); it('Should render filter bar, when required fields are provided', () => { @@ -141,14 +146,16 @@ describe('SearchBar', () => { wrapSearchBarInContext({ indexPatterns: [mockIndexPattern], showDatePicker: false, + showQueryInput: true, + showFilterBar: true, onFiltersUpdated: noop, filters: [], }) ); - expect(component.find(SEARCH_BAR_ROOT).length).toBe(1); - expect(component.find(FILTER_BAR).length).toBe(1); - expect(component.find(QUERY_BAR).length).toBe(0); + expect(component.find(SEARCH_BAR_ROOT)).toBeTruthy(); + expect(component.find(FILTER_BAR).length).toBeTruthy(); + expect(component.find(QUERY_BAR).length).toBeTruthy(); }); it('Should NOT render filter bar, if disabled', () => { @@ -162,9 +169,9 @@ describe('SearchBar', () => { }) ); - expect(component.find(SEARCH_BAR_ROOT).length).toBe(1); - expect(component.find(FILTER_BAR).length).toBe(0); - expect(component.find(QUERY_BAR).length).toBe(0); + expect(component.find(SEARCH_BAR_ROOT)).toBeTruthy(); + expect(component.find(FILTER_BAR).length).toBeFalsy(); + expect(component.find(QUERY_BAR).length).toBeTruthy(); }); it('Should render query bar, when required fields are provided', () => { @@ -177,12 +184,12 @@ describe('SearchBar', () => { }) ); - expect(component.find(SEARCH_BAR_ROOT).length).toBe(1); - expect(component.find(FILTER_BAR).length).toBe(0); - expect(component.find(QUERY_BAR).length).toBe(1); + expect(component.find(SEARCH_BAR_ROOT)).toBeTruthy(); + expect(component.find(FILTER_BAR).length).toBeFalsy(); + expect(component.find(QUERY_BAR).length).toBeTruthy(); }); - it('Should NOT render query bar, if disabled', () => { + it('Should NOT render the input query input, if disabled', () => { const component = mount( wrapSearchBarInContext({ indexPatterns: [mockIndexPattern], @@ -190,12 +197,13 @@ describe('SearchBar', () => { onQuerySubmit: noop, query: kqlQuery, showQueryBar: false, + showQueryInput: false, }) ); - expect(component.find(SEARCH_BAR_ROOT).length).toBe(1); - expect(component.find(FILTER_BAR).length).toBe(0); - expect(component.find(QUERY_BAR).length).toBe(0); + expect(component.find(SEARCH_BAR_ROOT)).toBeTruthy(); + expect(component.find(FILTER_BAR).length).toBeFalsy(); + expect(component.find(QUERY_INPUT).length).toBeFalsy(); }); it('Should render query bar and filter bar', () => { @@ -203,6 +211,7 @@ describe('SearchBar', () => { wrapSearchBarInContext({ indexPatterns: [mockIndexPattern], screenTitle: 'test screen', + showQueryInput: true, onQuerySubmit: noop, query: kqlQuery, filters: [], @@ -210,8 +219,9 @@ describe('SearchBar', () => { }) ); - expect(component.find(SEARCH_BAR_ROOT).length).toBe(1); - expect(component.find(FILTER_BAR).length).toBe(1); - expect(component.find(QUERY_BAR).length).toBe(1); + expect(component.find(SEARCH_BAR_ROOT)).toBeTruthy(); + expect(component.find(FILTER_BAR).length).toBeTruthy(); + expect(component.find(QUERY_BAR).length).toBeTruthy(); + expect(component.find(QUERY_INPUT).length).toBeTruthy(); }); }); diff --git a/src/plugins/unified_search/public/search_bar/search_bar.tsx b/src/plugins/unified_search/public/search_bar/search_bar.tsx index c829ab66bb60afb..a8681319ebc2194 100644 --- a/src/plugins/unified_search/public/search_bar/search_bar.tsx +++ b/src/plugins/unified_search/public/search_bar/search_bar.tsx @@ -11,22 +11,25 @@ import { InjectedIntl, injectI18n } from '@kbn/i18n-react'; import classNames from 'classnames'; import React, { Component } from 'react'; import { get, isEqual } from 'lodash'; -import { EuiIconProps } from '@elastic/eui'; +import { EuiIconProps, withEuiTheme, WithEuiThemeProps } from '@elastic/eui'; import memoizeOne from 'memoize-one'; import { METRIC_TYPE } from '@kbn/analytics'; import { Query, Filter } from '@kbn/es-query'; import { withKibana, KibanaReactContextValue } from '@kbn/kibana-react-plugin/public'; - import type { TimeHistoryContract, SavedQuery } from '@kbn/data-plugin/public'; import type { SavedQueryAttributes } from '@kbn/data-plugin/common'; import { IDataPluginServices } from '@kbn/data-plugin/public'; import { TimeRange } from '@kbn/data-plugin/common'; import { DataView } from '@kbn/data-views-plugin/public'; -import { FilterBar } from '../filter_bar'; -import QueryBarTopRow from '../query_string_input/query_bar_top_row'; + import { SavedQueryMeta, SaveQueryForm } from '../saved_query_form'; -import { SavedQueryManagementComponent } from '../saved_query_management'; +import { SavedQueryManagementList } from '../saved_query_management'; +import { QueryBarMenu, QueryBarMenuProps } from '../query_string_input/query_bar_menu'; +import type { DataViewPickerProps } from '../dataview_picker'; +import QueryBarTopRow from '../query_string_input/query_bar_top_row'; +import { FilterBar, FilterItems } from '../filter_bar'; +import { searchBarStyles } from './search_bar.styles'; export interface SearchBarInjectedDeps { kibana: KibanaReactContextValue; @@ -51,6 +54,7 @@ export interface SearchBarOwnProps { showDatePicker?: boolean; showAutoRefreshOnly?: boolean; filters?: Filter[]; + hiddenFilterPanelOptions?: QueryBarMenuProps['hiddenPanelOptions']; // Date picker isRefreshPaused?: boolean; refreshInterval?: number; @@ -77,19 +81,21 @@ export interface SearchBarOwnProps { isClearable?: boolean; iconType?: EuiIconProps['type']; nonKqlMode?: 'lucene' | 'text'; - nonKqlModeHelpText?: string; - // defines padding; use 'inPage' to avoid extra padding; use 'detached' if the searchBar appears at the very top of the view, without any wrapper + // defines padding and border; use 'inPage' to avoid any padding or border; + // use 'detached' if the searchBar appears at the very top of the view, without any wrapper displayStyle?: 'inPage' | 'detached'; // super update button background fill control fillSubmitButton?: boolean; + dataViewPickerComponentProps?: DataViewPickerProps; + showSubmitButton?: boolean; + isScreenshotMode?: boolean; } export type SearchBarProps = SearchBarOwnProps & SearchBarInjectedDeps; interface State { isFiltersVisible: boolean; - showSaveQueryModal: boolean; - showSaveNewQueryModal: boolean; + openQueryBarMenu: boolean; showSavedQueryPopover: boolean; currentProps?: SearchBarProps; query?: Query; @@ -97,11 +103,12 @@ interface State { dateRangeTo: string; } -class SearchBarUI extends Component { +class SearchBarUI extends Component { public static defaultProps = { showQueryBar: true, showFilterBar: true, showDatePicker: true, + showSubmitButton: true, showAutoRefreshOnly: false, }; @@ -168,8 +175,7 @@ class SearchBarUI extends Component { */ public state = { isFiltersVisible: true, - showSaveQueryModal: false, - showSaveNewQueryModal: false, + openQueryBarMenu: false, showSavedQueryPopover: false, currentProps: this.props, query: this.props.query ? { ...this.props.query } : undefined, @@ -193,13 +199,6 @@ class SearchBarUI extends Component { this.renderSavedQueryManagement.clear(); } - private shouldRenderQueryBar() { - const showDatePicker = this.props.showDatePicker || this.props.showAutoRefreshOnly; - const showQueryInput = - this.props.showQueryInput && this.props.indexPatterns && this.state.query; - return this.props.showQueryBar && (showDatePicker || showQueryInput); - } - private shouldRenderFilterBar() { return ( this.props.showFilterBar && @@ -266,11 +265,6 @@ class SearchBarUI extends Component { `Your query "${response.attributes.title}" was saved` ); - this.setState({ - showSaveQueryModal: false, - showSaveNewQueryModal: false, - }); - if (this.props.onSaved) { this.props.onSaved(response); } @@ -282,18 +276,6 @@ class SearchBarUI extends Component { } }; - public onInitiateSave = () => { - this.setState({ - showSaveQueryModal: true, - }); - }; - - public onInitiateSaveNew = () => { - this.setState({ - showSaveNewQueryModal: true, - }); - }; - public onQueryBarChange = (queryAndDateRange: { dateRange: TimeRange; query?: Query }) => { this.setState({ query: queryAndDateRange.query, @@ -305,6 +287,12 @@ class SearchBarUI extends Component { } }; + public toggleFilterBarMenuPopover = (value: boolean) => { + this.setState({ + openQueryBarMenu: value, + }); + }; + public onQueryBarSubmit = (queryAndDateRange: { dateRange?: TimeRange; query?: Query }) => { this.setState( { @@ -349,12 +337,110 @@ class SearchBarUI extends Component { } }; + private shouldShowDatePickerAsBadge() { + return this.shouldRenderFilterBar() && !this.props.showQueryInput; + } + public render() { + const { theme } = this.props; + const isScreenshotMode = this.props.isScreenshotMode === true; + const styles = searchBarStyles(theme); + const cssStyles = [ + styles.uniSearchBar, + this.props.displayStyle && styles[this.props.displayStyle], + isScreenshotMode && styles.hidden, + ]; + + const classes = classNames('uniSearchBar', { + [`uniSearchBar--hidden`]: isScreenshotMode, + [`uniSearchBar--${this.props.displayStyle}`]: this.props.displayStyle, + }); + const timeRangeForSuggestionsOverride = this.props.showDatePicker ? undefined : false; - let queryBar; - if (this.shouldRenderQueryBar()) { - queryBar = ( + const saveAsNewQueryFormComponent = ( + this.onSave(savedQueryMeta, true)} + onClose={() => this.setState({ openQueryBarMenu: false })} + showFilterOption={this.props.showFilterBar} + showTimeFilterOption={this.shouldRenderTimeFilterInSavedQueryForm()} + /> + ); + + const saveQueryFormComponent = ( + this.setState({ openQueryBarMenu: false })} + showFilterOption={this.props.showFilterBar} + showTimeFilterOption={this.shouldRenderTimeFilterInSavedQueryForm()} + /> + ); + + const queryBarMenu = ( + + ); + + let filterBar; + if (this.shouldRenderFilterBar()) { + filterBar = this.shouldShowDatePickerAsBadge() ? ( + + ) : ( + + ); + } + + return ( +
    { indexPatterns={this.props.indexPatterns} isLoading={this.props.isLoading} fillSubmitButton={this.props.fillSubmitButton || false} - prepend={ - this.props.showFilterBar && this.state.query - ? this.renderSavedQueryManagement( - this.props.onClearSavedQuery, - this.props.showSaveQuery, - this.props.savedQuery - ) - : undefined - } + prepend={this.props.showFilterBar || this.props.showQueryInput ? queryBarMenu : undefined} showDatePicker={this.props.showDatePicker} dateRangeFrom={this.state.dateRangeFrom} dateRangeTo={this.state.dateRangeTo} @@ -379,6 +457,7 @@ class SearchBarUI extends Component { refreshInterval={this.props.refreshInterval} showAutoRefreshOnly={this.props.showAutoRefreshOnly} showQueryInput={this.props.showQueryInput} + showAddFilter={this.props.showFilterBar} onRefresh={this.props.onRefresh} onRefreshChange={this.props.onRefreshChange} onChange={this.onQueryBarChange} @@ -386,70 +465,31 @@ class SearchBarUI extends Component { customSubmitButton={ this.props.customSubmitButton ? this.props.customSubmitButton : undefined } + showSubmitButton={this.props.showSubmitButton} dataTestSubj={this.props.dataTestSubj} indicateNoData={this.props.indicateNoData} placeholder={this.props.placeholder} isClearable={this.props.isClearable} iconType={this.props.iconType} nonKqlMode={this.props.nonKqlMode} - nonKqlModeHelpText={this.props.nonKqlModeHelpText} timeRangeForSuggestionsOverride={timeRangeForSuggestionsOverride} + filters={this.props.filters!} + onFiltersUpdated={this.props.onFiltersUpdated} + dataViewPickerComponentProps={this.props.dataViewPickerComponentProps} + showDatePickerAsBadge={this.shouldShowDatePickerAsBadge()} + filterBar={filterBar} + isScreenshotMode={this.props.isScreenshotMode} /> - ); - } - - let filterBar; - if (this.shouldRenderFilterBar()) { - const filterGroupClasses = classNames('globalFilterGroup__wrapper', { - 'globalFilterGroup__wrapper-isVisible': this.state.isFiltersVisible, - }); - - filterBar = ( -
    - -
    - ); - } - - const globalQueryBarClasses = classNames('globalQueryBar', { - 'globalQueryBar--inPage': this.props.displayStyle === 'inPage', - }); - - return ( -
    - {queryBar} - {filterBar} - - {this.state.showSaveQueryModal ? ( - this.setState({ showSaveQueryModal: false })} - showFilterOption={this.props.showFilterBar} - showTimeFilterOption={this.shouldRenderTimeFilterInSavedQueryForm()} - /> - ) : null} - {this.state.showSaveNewQueryModal ? ( - this.onSave(savedQueryMeta, true)} - onClose={() => this.setState({ showSaveNewQueryModal: false })} - showFilterOption={this.props.showFilterBar} - showTimeFilterOption={this.shouldRenderTimeFilterInSavedQueryForm()} - /> - ) : null}
    ); } + private hasFiltersOrQuery() { + const hasFilters = Boolean(this.props.filters && this.props.filters.length > 0); + const hasQuery = Boolean(this.state.query && this.state.query.query); + return hasFilters || hasQuery; + } + private renderSavedQueryManagement = memoizeOne( ( onClearSavedQuery: SearchBarOwnProps['onClearSavedQuery'], @@ -457,14 +497,14 @@ class SearchBarUI extends Component { savedQuery: SearchBarOwnProps['savedQuery'] ) => { const savedQueryManagement = onClearSavedQuery && ( - this.setState({ openQueryBarMenu: false })} + hasFiltersOrQuery={this.hasFiltersOrQuery()} /> ); @@ -475,4 +515,4 @@ class SearchBarUI extends Component { // Needed for React.lazy // eslint-disable-next-line import/no-default-export -export default injectI18n(withKibana(SearchBarUI)); +export default injectI18n(withEuiTheme(withKibana(SearchBarUI))); diff --git a/src/plugins/unified_search/public/typeahead/_index.scss b/src/plugins/unified_search/public/typeahead/_index.scss deleted file mode 100644 index c0ba5f05f589c81..000000000000000 --- a/src/plugins/unified_search/public/typeahead/_index.scss +++ /dev/null @@ -1 +0,0 @@ -@import 'suggestion'; diff --git a/src/plugins/unified_search/public/typeahead/_suggestion.scss b/src/plugins/unified_search/public/typeahead/_suggestion.scss index e466a52e7fc1081..a59e53a102d6c15 100644 --- a/src/plugins/unified_search/public/typeahead/_suggestion.scss +++ b/src/plugins/unified_search/public/typeahead/_suggestion.scss @@ -15,12 +15,16 @@ $kbnTypeaheadTypes: ( @include euiBottomShadowFlat; border-top-left-radius: $euiBorderRadius; border-top-right-radius: $euiBorderRadius; + // Clips the shadow so it doesn't show above the input (below) + clip-path: polygon(-50px -50px, calc(100% + 50px) -50px, calc(100% + 50px) 100%, -50px 100%); } .kbnTypeahead__popover--bottom { @include euiBottomShadow; border-bottom-left-radius: $euiBorderRadius; border-bottom-right-radius: $euiBorderRadius; + // Clips the shadow so it doesn't show above the input (top) + clip-path: polygon(-50px 1px, calc(100% + 50px) 1px, calc(100% + 50px) calc(100% + 50px), -50px calc(100% + 50px)); } .kbnTypeahead { @@ -59,7 +63,6 @@ $kbnTypeaheadTypes: ( .kbnTypeahead__item:first-child { border-bottom: none; - border-radius: $euiBorderRadius $euiBorderRadius 0 0; } .kbnTypeahead__item.active { diff --git a/src/plugins/unified_search/public/typeahead/suggestions_component.tsx b/src/plugins/unified_search/public/typeahead/suggestions_component.tsx index 75e446cf2d6e8be..ebeddfaaff81fb3 100644 --- a/src/plugins/unified_search/public/typeahead/suggestions_component.tsx +++ b/src/plugins/unified_search/public/typeahead/suggestions_component.tsx @@ -9,8 +9,7 @@ import React, { PureComponent, ReactNode } from 'react'; import { isEmpty } from 'lodash'; import classNames from 'classnames'; - -import styled from 'styled-components'; +import { css } from '@emotion/react'; import useRafState from 'react-use/lib/useRafState'; import { QuerySuggestion } from '../autocomplete'; @@ -146,15 +145,6 @@ export default class SuggestionsComponent extends PureComponent ` - position: absolute; - z-index: 4001; - left: ${props.left}px; - width: ${props.width}px; - ${props.verticalListPosition}`} -`; - const ResizableSuggestionsListDiv: React.FC<{ inputContainer: HTMLElement; suggestionsSize?: SuggestionsListSize; @@ -174,12 +164,16 @@ const ResizableSuggestionsListDiv: React.FC<{ ? `top: ${pageYOffset + containerRect.bottom - SUGGESTIONS_LIST_REQUIRED_TOP_OFFSET}px;` : `bottom: ${documentHeight - (pageYOffset + containerRect.top)}px;`; + const divPosition = css` + position: absolute; + z-index: 4001; + left: ${containerRect.left}px; + width: ${containerRect.width}px; + ${verticalListPosition} + `; + return ( - +
    - +
    ); }); diff --git a/src/plugins/unified_search/public/types.ts b/src/plugins/unified_search/public/types.ts index 29cf59f41a87122..f218106284ac219 100755 --- a/src/plugins/unified_search/public/types.ts +++ b/src/plugins/unified_search/public/types.ts @@ -8,6 +8,7 @@ import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; +import type { ScreenshotModePluginStart } from '@kbn/screenshot-mode-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public'; import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; @@ -29,6 +30,7 @@ export interface UnifiedSearchStartDependencies { fieldFormats: FieldFormatsStart; data: DataPublicPluginStart; uiActions: UiActionsStart; + screenshotMode?: ScreenshotModePluginStart; } /** @@ -54,3 +56,13 @@ export interface UnifiedSearchPublicPluginStart { */ ui: UnifiedSearchPublicPluginStartUi; } + +/** + * Filter options from Unified Search menu panels + */ +export type FilterPanelOption = + | 'pinFilter' + | 'editFilter' + | 'negateFilter' + | 'disableFilter' + | 'deleteFilter'; diff --git a/src/plugins/usage_collection/server/collector/__snapshots__/collector_set.test.ts.snap b/src/plugins/usage_collection/server/collector/__snapshots__/collector_set.test.ts.snap new file mode 100644 index 000000000000000..a466fbcb0284101 --- /dev/null +++ b/src/plugins/usage_collection/server/collector/__snapshots__/collector_set.test.ts.snap @@ -0,0 +1,107 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CollectorSet bulkFetch skips collectors that are not ready 1`] = ` +Array [ + Object { + "result": Object {}, + "type": "ready_col", + }, + Object { + "result": Object { + "failed": Object { + "count": 0, + "names": Array [], + }, + "fetch_duration_breakdown": Array [ + Object { + "duration": 0, + "name": "ready_col", + }, + ], + "is_ready_duration_breakdown": Array [ + Object { + "duration": 0, + "name": "ready_col", + }, + Object { + "duration": 0, + "name": "not_ready_col", + }, + ], + "not_ready": Object { + "count": 1, + "names": Array [ + "not_ready_col", + ], + }, + "not_ready_timeout": Object { + "count": 0, + "names": Array [], + }, + "succeeded": Object { + "count": 1, + "names": Array [ + "ready_col", + ], + }, + "total_duration": 0, + "total_fetch_duration": 0, + "total_is_ready_duration": 0, + }, + "type": "usage_collector_stats", + }, +] +`; + +exports[`CollectorSet bulkFetch skips collectors that have timed out 1`] = ` +Array [ + Object { + "result": Object {}, + "type": "ready_col", + }, + Object { + "result": Object { + "failed": Object { + "count": 0, + "names": Array [], + }, + "fetch_duration_breakdown": Array [ + Object { + "duration": 0, + "name": "ready_col", + }, + ], + "is_ready_duration_breakdown": Array [ + Object { + "duration": Any, + "name": "ready_col", + }, + Object { + "duration": Any, + "name": "timeout_col", + }, + ], + "not_ready": Object { + "count": 0, + "names": Array [], + }, + "not_ready_timeout": Object { + "count": 1, + "names": Array [ + "timeout_col", + ], + }, + "succeeded": Object { + "count": 1, + "names": Array [ + "ready_col", + ], + }, + "total_duration": Any, + "total_fetch_duration": 0, + "total_is_ready_duration": Any, + }, + "type": "usage_collector_stats", + }, +] +`; diff --git a/src/plugins/usage_collection/server/collector/collector_set.test.ts b/src/plugins/usage_collection/server/collector/collector_set.test.ts index a3e8d2de19e6078..1987055e6faec19 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.test.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.test.ts @@ -102,6 +102,11 @@ describe('CollectorSet', () => { not_ready_timeout: { count: 0, names: [] }, succeeded: { count: 1, names: ['MY_TEST_COLLECTOR'] }, failed: { count: 0, names: [] }, + fetch_duration_breakdown: [{ name: 'MY_TEST_COLLECTOR', duration: 0 }], + is_ready_duration_breakdown: [{ name: 'MY_TEST_COLLECTOR', duration: 0 }], + total_duration: 0, + total_fetch_duration: 0, + total_is_ready_duration: 0, }, }, ]); @@ -132,6 +137,11 @@ describe('CollectorSet', () => { not_ready_timeout: { count: 0, names: [] }, succeeded: { count: 0, names: [] }, failed: { count: 1, names: ['MY_TEST_COLLECTOR'] }, + fetch_duration_breakdown: [{ name: 'MY_TEST_COLLECTOR', duration: 0 }], + is_ready_duration_breakdown: [{ name: 'MY_TEST_COLLECTOR', duration: 0 }], + total_duration: 0, + total_fetch_duration: 0, + total_is_ready_duration: 0, }, }, ]); @@ -161,6 +171,11 @@ describe('CollectorSet', () => { not_ready_timeout: { count: 0, names: [] }, succeeded: { count: 1, names: ['MY_TEST_COLLECTOR'] }, failed: { count: 0, names: [] }, + fetch_duration_breakdown: [{ name: 'MY_TEST_COLLECTOR', duration: 0 }], + is_ready_duration_breakdown: [{ name: 'MY_TEST_COLLECTOR', duration: 0 }], + total_duration: 0, + total_fetch_duration: 0, + total_is_ready_duration: 0, }, }, ]); @@ -189,6 +204,11 @@ describe('CollectorSet', () => { not_ready_timeout: { count: 0, names: [] }, succeeded: { count: 1, names: ['MY_TEST_COLLECTOR'] }, failed: { count: 0, names: [] }, + fetch_duration_breakdown: [{ name: 'MY_TEST_COLLECTOR', duration: 0 }], + is_ready_duration_breakdown: [{ name: 'MY_TEST_COLLECTOR', duration: 0 }], + total_duration: 0, + total_fetch_duration: 0, + total_is_ready_duration: 0, }, }, ]); @@ -354,39 +374,52 @@ describe('CollectorSet', () => { expect(mockIsNotReady).toBeCalledTimes(1); expect(mockNonReadyFetch).toBeCalledTimes(0); - expect(results).toMatchInlineSnapshot(` - Array [ - Object { - "result": Object {}, - "type": "ready_col", - }, - Object { - "result": Object { - "failed": Object { - "count": 0, - "names": Array [], - }, - "not_ready": Object { - "count": 1, - "names": Array [ - "not_ready_col", - ], + expect(results).toMatchSnapshot([ + { + result: {}, + type: 'ready_col', + }, + { + result: { + failed: { + count: 0, + names: [], + }, + fetch_duration_breakdown: [ + { + name: 'ready_col', + duration: 0, }, - "not_ready_timeout": Object { - "count": 0, - "names": Array [], + ], + is_ready_duration_breakdown: [ + { + name: 'ready_col', + duration: 0, }, - "succeeded": Object { - "count": 1, - "names": Array [ - "ready_col", - ], + { + name: 'not_ready_col', + duration: 0, }, + ], + not_ready: { + count: 1, + names: ['not_ready_col'], + }, + not_ready_timeout: { + count: 0, + names: [], + }, + succeeded: { + count: 1, + names: ['ready_col'], }, - "type": "usage_collector_stats", + total_duration: 0, + total_fetch_duration: 0, + total_is_ready_duration: 0, }, - ] - `); + type: 'usage_collector_stats', + }, + ]); }); it('skips collectors that have timed out', async () => { @@ -428,39 +461,52 @@ describe('CollectorSet', () => { expect(mockTimedOutReady).toBeCalledTimes(1); expect(mockNonReadyFetch).toBeCalledTimes(0); - expect(results).toMatchInlineSnapshot(` - Array [ - Object { - "result": Object {}, - "type": "ready_col", - }, - Object { - "result": Object { - "failed": Object { - "count": 0, - "names": Array [], - }, - "not_ready": Object { - "count": 0, - "names": Array [], + expect(results).toMatchSnapshot([ + { + result: {}, + type: 'ready_col', + }, + { + result: { + failed: { + count: 0, + names: [], + }, + fetch_duration_breakdown: [ + { + name: 'ready_col', + duration: 0, }, - "not_ready_timeout": Object { - "count": 1, - "names": Array [ - "timeout_col", - ], + ], + is_ready_duration_breakdown: [ + { + name: 'ready_col', + duration: expect.any(Number), }, - "succeeded": Object { - "count": 1, - "names": Array [ - "ready_col", - ], + { + name: 'timeout_col', + duration: expect.any(Number), }, + ], + not_ready: { + count: 0, + names: [], + }, + not_ready_timeout: { + count: 1, + names: ['timeout_col'], + }, + succeeded: { + count: 1, + names: ['ready_col'], }, - "type": "usage_collector_stats", + total_duration: expect.any(Number), + total_fetch_duration: 0, + total_is_ready_duration: expect.any(Number), }, - ] - `); + type: 'usage_collector_stats', + }, + ]); }); it('passes context to fetch', async () => { diff --git a/src/plugins/usage_collection/server/collector/collector_set.ts b/src/plugins/usage_collection/server/collector/collector_set.ts index 90cf7e6450ea45e..8251b95a1beb8b5 100644 --- a/src/plugins/usage_collection/server/collector/collector_set.ts +++ b/src/plugins/usage_collection/server/collector/collector_set.ts @@ -7,6 +7,7 @@ */ import { withTimeout } from '@kbn/std'; import { snakeCase } from 'lodash'; + import type { Logger, ElasticsearchClient, @@ -15,10 +16,13 @@ import type { ExecutionContextSetup, } from '@kbn/core/server'; import { Collector } from './collector'; -import type { ICollector, CollectorOptions } from './types'; +import type { ICollector, CollectorOptions, CollectorFetchContext } from './types'; import { UsageCollector, UsageCollectorOptions } from './usage_collector'; import { DEFAULT_MAXIMUM_WAIT_TIME_FOR_ALL_COLLECTORS_IN_S } from '../../common/constants'; +import { createPerformanceObsHook, perfTimerify } from './measure_duration'; +import { usageCollectorsStatsCollector } from './collector_stats'; +const SECOND_IN_MS = 1000; // Needed for the general array containing all the collectors. We don't really care about their types here // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyCollector = ICollector; @@ -34,14 +38,6 @@ export interface CollectorSetConfig { collectors?: AnyCollector[]; } -// Schema manually added in src/plugins/telemetry/schema/oss_root.json under `stack_stats.kibana.plugins.usage_collector_stats` -interface CollectorStats { - not_ready: { count: number; names: string[] }; - not_ready_timeout: { count: number; names: string[] }; - succeeded: { count: number; names: string[] }; - failed: { count: number; names: string[] }; -} - export class CollectorSet { private readonly logger: Logger; private readonly executionContext: ExecutionContextSetup; @@ -115,22 +111,37 @@ export class CollectorSet { ); } - const secondInMs = 1000; + const timeoutMs = this.maximumWaitTimeForAllCollectorsInS * SECOND_IN_MS; const collectorsWithStatus: CollectorWithStatus[] = await Promise.all( [...collectors.values()].map(async (collector) => { - const isReadyWithTimeout = await withTimeout({ - promise: (async (): Promise => { + const wrappedPromise = perfTimerify( + `is_ready_${collector.type}`, + async (): Promise => { try { return await collector.isReady(); } catch (err) { this.logger.debug(`Collector ${collector.type} failed to get ready. ${err}`); return false; } - })(), - timeoutMs: this.maximumWaitTimeForAllCollectorsInS * secondInMs, + } + ); + + const isReadyWithTimeout = await withTimeout({ + promise: wrappedPromise(), + timeoutMs, }); - return { isReadyWithTimeout, collector }; + if (isReadyWithTimeout.timedout) { + return { isReadyWithTimeout, collector }; + } + + return { + isReadyWithTimeout: { + value: isReadyWithTimeout.value, + timedout: isReadyWithTimeout.timedout, + }, + collector, + }; }) ); @@ -176,55 +187,113 @@ export class CollectorSet { }; }; + private fetchCollector = async ( + collector: AnyCollector, + context: CollectorFetchContext + ): Promise<{ + result?: unknown; + status: 'failed' | 'success'; + type: string; + }> => { + const { type } = collector; + this.logger.debug(`Fetching data from ${type} collector`); + const executionContext: KibanaExecutionContext = { + type: 'usage_collection', + name: 'collector.fetch', + id: type, + description: `Fetch method in the Collector "${type}"`, + }; + + try { + const result = await this.executionContext.withContext(executionContext, () => + collector.fetch(context) + ); + return { type, result, status: 'success' as const }; + } catch (err) { + this.logger.warn(err); + this.logger.warn(`Unable to fetch data from ${type} collector`); + return { type, status: 'failed' as const }; + } + }; + public bulkFetch = async ( esClient: ElasticsearchClient, soClient: SavedObjectsClientContract, collectors: Map = this.collectors ) => { this.logger.debug(`Getting ready collectors`); + const getMarks = createPerformanceObsHook(); const { readyCollectors, nonReadyCollectorTypes, timedOutCollectorsTypes } = await this.getReadyCollectors(collectors); - const collectorStats: CollectorStats = { - not_ready: { count: nonReadyCollectorTypes.length, names: nonReadyCollectorTypes }, - not_ready_timeout: { count: timedOutCollectorsTypes.length, names: timedOutCollectorsTypes }, - succeeded: { count: 0, names: [] }, - failed: { count: 0, names: [] }, - }; + // freeze object to prevent collectors from mutating it. + const context = Object.freeze({ esClient, soClient }); - const responses = await Promise.all( + const fetchExecutions = await Promise.all( readyCollectors.map(async (collector) => { - this.logger.debug(`Fetching data from ${collector.type} collector`); - try { - const context = { esClient, soClient }; - const executionContext: KibanaExecutionContext = { - type: 'usage_collection', - name: 'collector.fetch', - id: collector.type, - description: `Fetch method in the Collector "${collector.type}"`, - }; - const result = await this.executionContext.withContext(executionContext, () => - collector.fetch(context) - ); - collectorStats.succeeded.names.push(collector.type); - return { type: collector.type, result }; - } catch (err) { - this.logger.warn(err); - this.logger.warn(`Unable to fetch data from ${collector.type} collector`); - collectorStats.failed.names.push(collector.type); - } + const wrappedPromise = perfTimerify( + `fetch_${collector.type}`, + async () => await this.fetchCollector(collector, context) + ); + + return await wrappedPromise(); }) ); + const durationMarks = getMarks(); + + const isReadyExecutionDurationByType = [ + ...readyCollectors.map(({ type }) => { + // should always find a duration, fallback to 0 in case something unexpected happened + const duration = durationMarks[`is_ready_${type}`] || 0; + return { duration, type }; + }), + ...nonReadyCollectorTypes.map((type) => { + // should always find a duration, fallback to 0 in case something unexpected happened + const duration = durationMarks[`is_ready_${type}`] || 0; + return { duration, type }; + }), + ...timedOutCollectorsTypes.map((type) => { + const timeoutMs = this.maximumWaitTimeForAllCollectorsInS * SECOND_IN_MS; + // if undefined default to timeoutMs since the collector timedout + const duration = durationMarks[`is_ready_${type}`] || timeoutMs; + return { duration, type }; + }), + ]; + + const fetchExecutionDurationByType = fetchExecutions.map(({ type, status }) => { + // should always find a duration, fallback to 0 in case something unexpected happened + const duration = durationMarks[`fetch_${type}`] || 0; + return { duration, type, status }; + }); - collectorStats.succeeded.count = collectorStats.succeeded.names.length; - collectorStats.failed.count = collectorStats.failed.names.length; - - // Treat it as just another "collector" - responses.push({ type: 'usage_collector_stats', result: collectorStats }); - - return responses.filter( - (response): response is { type: string; result: unknown } => typeof response !== 'undefined' + const usageCollectorStats = usageCollectorsStatsCollector( + // pass `this` as `usageCollection` to the collector to mimic + // registering a collector via usageCollection.SetupContract + this, + { + // isReady stats + nonReadyCollectorTypes, + timedOutCollectorsTypes, + isReadyExecutionDurationByType, + + // fetch stats + fetchExecutionDurationByType, + } ); + + return [ + ...fetchExecutions + // pluck type and result from collector object + .map(({ type, result }) => ({ type, result })) + // only keep data of collectors thar returned a result + .filter( + (response): response is { type: string; result: unknown } => + typeof response?.result !== 'undefined' + ), + + // Treat collector stats as just another "collector" + { type: usageCollectorStats.type, result: usageCollectorStats.fetch(context) }, + ]; }; /* diff --git a/src/plugins/usage_collection/server/collector/collector_stats/README.md b/src/plugins/usage_collection/server/collector/collector_stats/README.md new file mode 100644 index 000000000000000..dcd65329c63dcbc --- /dev/null +++ b/src/plugins/usage_collection/server/collector/collector_stats/README.md @@ -0,0 +1,70 @@ +## Collector Stats Collector + +The `usage_collector_stats` collector adds telemetry around the execution duration grabbing usage and the status of the collectors: +- Total number and names of collectors that return `true` from `isReady` +- Total number and names of collectors that return `false` from from `isReady` +- Total number and names of collectors that timeout from from `isReady` +- Total number and names of ready collectors that successfully return data from `fetch` +- Total number and names of ready collectors that fail to return data from `fetch` +- Total execution duration to grab all collectors +- Total execution duration to get the `isReady` state of each collector +- Total execution duration to get the `fetch` objects from each collector +- Breakdown per collector type with details on the execution duration for `fetch` and `isReady` + +The overall durations show the overall health of the collection mechanism, while the breakdown objects help diagnose specific collectors and improve upon them. + +## Why is this in telemetry and not in CI? +Adding limits and checks in CI is a good idea for catching early issues. Collecting these metrics via telemetry will also help us identify bottlenecks against real-world use cases from Kibanas in the wild. + +## What does the usage collector stats look like? + +The collector can be found under `stack_stats.kibana.plugins.usage_collector_stats` and looks like this: + +```json +"usage_collector_stats": { + "not_ready": { + "count": 1, + "names": [ + "cloud_provider" + ] + }, + "not_ready_timeout": { + "count": 0, + "names": [] + }, + "succeeded": { + "count": 54, + "names": [ + "task_manager", + "ui_counters", + "usage_counters", + "kibana_stats", + "kibana", + ... + ] + }, + "failed": { + "count": 0, + "names": [] + }, + "total_is_ready_duration": 0.07500024700000003, + "total_fetch_duration": 0.35939233100000006, + "total_duration": 0.4343925780000001, + "is_ready_duration_breakdown": { + { "name": "task_manager", "duration": 0.001828041 }, + { "name": "ui_counters", "duration": 0.001790625 }, + { "name": "usage_counters", "duration": 0.001778125 }, + { "name": "kibana_stats", "duration": 0.001764709 }, + { "name": "kibana", "duration": 0.001748917 }, + ... + }, + "fetch_duration_breakdown": { + { "name": "task_manager", "duration": 0.011157708 }, + { "name": "ui_counters", "duration": 0.011002625 }, + { "name": "usage_counters", "duration": 0.009945833 }, + { "name": "kibana_stats", "duration": 0.009424458 }, + { "name": "kibana", "duration": 0.009406416 }, + ... + } +} +``` \ No newline at end of file diff --git a/src/plugins/usage_collection/server/collector/collector_stats/index.ts b/src/plugins/usage_collection/server/collector/collector_stats/index.ts new file mode 100644 index 000000000000000..374170ad99a6a91 --- /dev/null +++ b/src/plugins/usage_collection/server/collector/collector_stats/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 { usageCollectorsStatsCollector } from './usage_collector_stats_collector'; diff --git a/src/plugins/usage_collection/server/collector/collector_stats/schema.ts b/src/plugins/usage_collection/server/collector/collector_stats/schema.ts new file mode 100644 index 000000000000000..c95301f79a2969f --- /dev/null +++ b/src/plugins/usage_collection/server/collector/collector_stats/schema.ts @@ -0,0 +1,139 @@ +/* + * 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 { MakeSchemaFrom } from '../types'; +import type { CollectorsStats } from './usage_collector_stats_collector'; + +export const collectorsStatsSchema: MakeSchemaFrom = { + total_duration: { + type: 'long', + _meta: { + description: + 'The total execution duration to grab usage stats for all collectors in milliseconds', + }, + }, + total_is_ready_duration: { + type: 'long', + _meta: { + description: + 'The total execution duration of the isReady function for all collectors in milliseconds', + }, + }, + total_fetch_duration: { + type: 'long', + _meta: { + description: + 'The total execution duration of the fetch function for all ready collectors in milliseconds', + }, + }, + is_ready_duration_breakdown: { + type: 'array', + items: { + name: { + type: 'keyword', + _meta: { + description: 'The name of the collector', + }, + }, + duration: { + type: 'long', + _meta: { + description: + 'The execution duration of the isReady function for the collector in milliseconds', + }, + }, + }, + }, + fetch_duration_breakdown: { + type: 'array', + items: { + name: { + type: 'keyword', + _meta: { + description: 'The name of the collector', + }, + }, + duration: { + type: 'long', + _meta: { + description: + 'The execution duration of the fetch function for the collector in milliseconds', + }, + }, + }, + }, + not_ready: { + count: { + type: 'short', + _meta: { + description: 'The number of collectors that returned false from the isReady function', + }, + }, + names: { + type: 'array', + items: { + type: 'keyword', + _meta: { + description: + 'The name of the of collectors that returned false from the isReady function', + }, + }, + }, + }, + not_ready_timeout: { + count: { + type: 'short', + _meta: { + description: 'The number of collectors that timedout during the isReady function', + }, + }, + names: { + type: 'array', + items: { + type: 'keyword', + _meta: { + description: 'The name of collectors that timedout during the isReady function', + }, + }, + }, + }, + succeeded: { + count: { + type: 'short', + _meta: { + description: 'The number of collectors that returned true from the fetch function', + }, + }, + names: { + type: 'array', + items: { + type: 'keyword', + _meta: { + description: 'The name of the of collectors that returned true from the fetch function', + }, + }, + }, + }, + failed: { + count: { + type: 'short', + _meta: { + description: 'The number of collectors that threw an error from the fetch function', + }, + }, + names: { + type: 'array', + items: { + type: 'keyword', + _meta: { + description: 'The name of the of collectors that threw an error from the fetch function', + }, + }, + }, + }, +}; diff --git a/src/plugins/usage_collection/server/collector/collector_stats/usage_collector_stats_collector.test.ts b/src/plugins/usage_collection/server/collector/collector_stats/usage_collector_stats_collector.test.ts new file mode 100644 index 000000000000000..6df345343be0c71 --- /dev/null +++ b/src/plugins/usage_collection/server/collector/collector_stats/usage_collector_stats_collector.test.ts @@ -0,0 +1,122 @@ +/* + * 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 { + usageCollectorsStatsCollector, + CollectorsStatsCollectorParams, +} from './usage_collector_stats_collector'; +import { UsageCollector } from '../usage_collector'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { createCollectorFetchContextMock } from '../../mocks'; + +describe('usageCollectorsStatsCollector', () => { + const logger = loggingSystemMock.createLogger(); + const mockFetchContext = createCollectorFetchContextMock(); + const mockMakeUsageCollector = jest.fn().mockImplementation((args) => { + return new UsageCollector(logger, args); + }); + const mockCollectorSet = { makeUsageCollector: mockMakeUsageCollector }; + + const createCollectorStats = ( + params?: Partial + ): CollectorsStatsCollectorParams => ({ + fetchExecutionDurationByType: [], + isReadyExecutionDurationByType: [], + nonReadyCollectorTypes: [], + timedOutCollectorsTypes: [], + ...params, + }); + + it('calls makeUsageCollector to create a collector', () => { + const collectorStats = createCollectorStats(); + const collector = usageCollectorsStatsCollector(mockCollectorSet, collectorStats); + expect(mockMakeUsageCollector).toBeCalledTimes(1); + expect(collector.type).toMatchInlineSnapshot(`"usage_collector_stats"`); + expect(typeof collector.fetch).toBe('function'); + expect(collector).toBeInstanceOf(UsageCollector); + }); + + it('returns collector stats totals and breakdowns from fetch', async () => { + const collectorStats = createCollectorStats({ + fetchExecutionDurationByType: [ + { duration: 1.2, status: 'success', type: 'SUCCESS_COLLECTOR' }, + { duration: 8, status: 'success', type: 'SUCCESS_COLLECTOR_2' }, + { duration: 2.2, status: 'failed', type: 'FAILED_COLLECTOR' }, + ], + isReadyExecutionDurationByType: [ + { duration: 10.2, type: 'SUCCESS_COLLECTOR' }, + { duration: 4.2, type: 'SUCCESS_COLLECTOR_2' }, + { duration: 12, type: 'FAILED_COLLECTOR' }, + ], + nonReadyCollectorTypes: ['NON_READY_COLLECTOR'], + timedOutCollectorsTypes: ['TIMED_OUT_READY_COLLECTOR'], + }); + const collector = usageCollectorsStatsCollector(mockCollectorSet, collectorStats); + const result = await collector.fetch(mockFetchContext); + expect(result).toMatchInlineSnapshot(` + Object { + "failed": Object { + "count": 1, + "names": Array [ + "FAILED_COLLECTOR", + ], + }, + "fetch_duration_breakdown": Array [ + Object { + "duration": 1.2, + "name": "SUCCESS_COLLECTOR", + }, + Object { + "duration": 8, + "name": "SUCCESS_COLLECTOR_2", + }, + Object { + "duration": 2.2, + "name": "FAILED_COLLECTOR", + }, + ], + "is_ready_duration_breakdown": Array [ + Object { + "duration": 10.2, + "name": "SUCCESS_COLLECTOR", + }, + Object { + "duration": 4.2, + "name": "SUCCESS_COLLECTOR_2", + }, + Object { + "duration": 12, + "name": "FAILED_COLLECTOR", + }, + ], + "not_ready": Object { + "count": 1, + "names": Array [ + "NON_READY_COLLECTOR", + ], + }, + "not_ready_timeout": Object { + "count": 1, + "names": Array [ + "TIMED_OUT_READY_COLLECTOR", + ], + }, + "succeeded": Object { + "count": 2, + "names": Array [ + "SUCCESS_COLLECTOR", + "SUCCESS_COLLECTOR_2", + ], + }, + "total_duration": 37.8, + "total_fetch_duration": 11.399999999999999, + "total_is_ready_duration": 26.4, + } + `); + }); +}); diff --git a/src/plugins/usage_collection/server/collector/collector_stats/usage_collector_stats_collector.ts b/src/plugins/usage_collection/server/collector/collector_stats/usage_collector_stats_collector.ts new file mode 100644 index 000000000000000..4bf7754e07018cb --- /dev/null +++ b/src/plugins/usage_collection/server/collector/collector_stats/usage_collector_stats_collector.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { sumBy } from 'lodash'; +import { collectorsStatsSchema } from './schema'; +import type { CollectorSet } from '../collector_set'; + +export interface CollectorsStats { + not_ready: { count: number; names: string[] }; + not_ready_timeout: { count: number; names: string[] }; + succeeded: { count: number; names: string[] }; + failed: { count: number; names: string[] }; + + total_duration: number; + total_is_ready_duration: number; + total_fetch_duration: number; + is_ready_duration_breakdown: Array<{ name: string; duration: number }>; + fetch_duration_breakdown: Array<{ name: string; duration: number }>; +} + +export interface CollectorsStatsCollectorParams { + nonReadyCollectorTypes: string[]; + timedOutCollectorsTypes: string[]; + isReadyExecutionDurationByType: Array<{ duration: number; type: string }>; + fetchExecutionDurationByType: Array<{ + duration: number; + type: string; + status: 'failed' | 'success'; + }>; +} + +export const usageCollectorsStatsCollector = ( + usageCollection: Pick, + { + nonReadyCollectorTypes, + timedOutCollectorsTypes, + isReadyExecutionDurationByType, + fetchExecutionDurationByType, + }: CollectorsStatsCollectorParams +) => { + return usageCollection.makeUsageCollector({ + type: 'usage_collector_stats', + isReady: () => true, + schema: collectorsStatsSchema, + fetch: () => { + const totalIsReadyDuration = sumBy(isReadyExecutionDurationByType, 'duration'); + const totalFetchDuration = sumBy(fetchExecutionDurationByType, 'duration'); + + const succeededCollectorTypes = fetchExecutionDurationByType + .filter(({ status }) => status === 'success') + .map(({ type }) => type); + const failedCollectorTypes = fetchExecutionDurationByType + .filter(({ status }) => status === 'failed') + .map(({ type }) => type); + + const collectorsStats: CollectorsStats = { + // isReady and fetch stats + not_ready: { count: nonReadyCollectorTypes.length, names: nonReadyCollectorTypes }, + not_ready_timeout: { + count: timedOutCollectorsTypes.length, + names: timedOutCollectorsTypes, + }, + succeeded: { count: succeededCollectorTypes.length, names: succeededCollectorTypes }, + failed: { count: failedCollectorTypes.length, names: failedCollectorTypes }, + + // total durations + total_is_ready_duration: totalIsReadyDuration, + total_fetch_duration: totalFetchDuration, + total_duration: totalIsReadyDuration + totalFetchDuration, + + // durations breakdown + is_ready_duration_breakdown: isReadyExecutionDurationByType.map( + ({ type: name, duration }) => ({ name, duration }) + ), + fetch_duration_breakdown: fetchExecutionDurationByType.map(({ type: name, duration }) => ({ + name, + duration, + })), + }; + + return collectorsStats; + }, + }); +}; diff --git a/src/plugins/usage_collection/server/collector/measure_duration.ts b/src/plugins/usage_collection/server/collector/measure_duration.ts new file mode 100644 index 000000000000000..aa17a39d6bf3b54 --- /dev/null +++ b/src/plugins/usage_collection/server/collector/measure_duration.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 { PerformanceObserver, performance } from 'perf_hooks'; + +export const createPerformanceObsHook = () => { + const marks: Record = {}; + const obs = new PerformanceObserver((items) => { + for (const { duration, name } of items.getEntries()) { + marks[name] = duration; + } + + performance.clearMarks(); + }); + + obs.observe({ entryTypes: ['function'] }); + + // teardown function returns the marked measurements. + // returning the data after teardown ensures that we proprely teardown + // the observer. + return () => { + obs.disconnect(); + return marks; + }; +}; + +/** + * A wrapper around performance.timerify which defined the name of the returned + * wrapped function to help identify observed function types inside the `PerformanceObserver`. + * + * @param name name of the function used to track the performance of the function execution + * @param fn the function to be wrapped by the performance.timerify method. + * @returns + */ +export const perfTimerify = unknown>(name: string, fn: T) => { + return performance.timerify(Object.defineProperty(fn, 'name', { value: name })); +}; diff --git a/src/plugins/vis_default_editor/kibana.json b/src/plugins/vis_default_editor/kibana.json index e6a56bd65fcc773..240eb7ccab6fa87 100644 --- a/src/plugins/vis_default_editor/kibana.json +++ b/src/plugins/vis_default_editor/kibana.json @@ -3,7 +3,16 @@ "version": "kibana", "ui": true, "optionalPlugins": ["visualizations"], - "requiredBundles": ["unifiedSearch", "kibanaUtils", "kibanaReact", "data", "fieldFormats", "discover", "esUiShared"], + "requiredBundles": [ + "unifiedSearch", + "kibanaUtils", + "kibanaReact", + "data", + "fieldFormats", + "discover", + "esUiShared", + "visualizations" + ], "owner": { "name": "Vis Editors", "githubTeam": "kibana-vis-editors" diff --git a/src/plugins/vis_default_editor/public/components/options/legend_size_settings.test.tsx b/src/plugins/vis_default_editor/public/components/options/legend_size_settings.test.tsx new file mode 100644 index 000000000000000..3eeb93e6155df19 --- /dev/null +++ b/src/plugins/vis_default_editor/public/components/options/legend_size_settings.test.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 React from 'react'; +import { LegendSizeSettings } from './legend_size_settings'; +import { LegendSize, DEFAULT_LEGEND_SIZE } from '@kbn/visualizations-plugin/public'; +import { EuiSuperSelect } from '@elastic/eui'; +import { shallow } from 'enzyme'; + +describe('legend size settings', () => { + it('select is disabled if not vertical legend', () => { + const instance = shallow( + {}} + isVerticalLegend={false} + showAutoOption={true} + /> + ); + + expect(instance.find(EuiSuperSelect).props().disabled).toBeTruthy(); + }); + + it('reflects current setting in select', () => { + const CURRENT_SIZE = LegendSize.SMALL; + + const instance = shallow( + {}} + isVerticalLegend={true} + showAutoOption={true} + /> + ); + + expect(instance.find(EuiSuperSelect).props().valueOfSelected).toBe(CURRENT_SIZE); + }); + + it('allows user to select a new option', () => { + const onSizeChange = jest.fn(); + + const instance = shallow( + + ); + + const onChange = instance.find(EuiSuperSelect).props().onChange; + + onChange(LegendSize.EXTRA_LARGE); + onChange(DEFAULT_LEGEND_SIZE); + + expect(onSizeChange).toHaveBeenNthCalledWith(1, LegendSize.EXTRA_LARGE); + expect(onSizeChange).toHaveBeenNthCalledWith(2, undefined); + }); + + it('hides "auto" option if visualization not using it', () => { + const getOptions = (showAutoOption: boolean) => + shallow( + {}} + isVerticalLegend={true} + showAutoOption={showAutoOption} + /> + ) + .find(EuiSuperSelect) + .props().options; + + const autoOption = expect.objectContaining({ value: LegendSize.AUTO }); + + expect(getOptions(true)).toContainEqual(autoOption); + expect(getOptions(false)).not.toContainEqual(autoOption); + }); +}); diff --git a/src/plugins/vis_default_editor/public/components/options/legend_size_settings.tsx b/src/plugins/vis_default_editor/public/components/options/legend_size_settings.tsx index 768db7d3dd78e24..bbe47295c99e662 100644 --- a/src/plugins/vis_default_editor/public/components/options/legend_size_settings.tsx +++ b/src/plugins/vis_default_editor/public/components/options/legend_size_settings.tsx @@ -10,27 +10,11 @@ import React, { useCallback, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiFormRow, EuiSuperSelect, EuiToolTip } from '@elastic/eui'; +import { LegendSize, DEFAULT_LEGEND_SIZE } from '@kbn/visualizations-plugin/public'; -enum LegendSizes { - AUTO = '0', - SMALL = '80', - MEDIUM = '130', - LARGE = '180', - EXTRA_LARGE = '230', -} - -const legendSizeOptions: Array<{ value: LegendSizes; inputDisplay: string }> = [ +const legendSizeOptions: Array<{ value: LegendSize; inputDisplay: string }> = [ { - value: LegendSizes.AUTO, - inputDisplay: i18n.translate( - 'visDefaultEditor.options.legendSizeSetting.legendSizeOptions.auto', - { - defaultMessage: 'Auto', - } - ), - }, - { - value: LegendSizes.SMALL, + value: LegendSize.SMALL, inputDisplay: i18n.translate( 'visDefaultEditor.options.legendSizeSetting.legendSizeOptions.small', { @@ -39,7 +23,7 @@ const legendSizeOptions: Array<{ value: LegendSizes; inputDisplay: string }> = [ ), }, { - value: LegendSizes.MEDIUM, + value: LegendSize.MEDIUM, inputDisplay: i18n.translate( 'visDefaultEditor.options.legendSizeSetting.legendSizeOptions.medium', { @@ -48,7 +32,7 @@ const legendSizeOptions: Array<{ value: LegendSizes; inputDisplay: string }> = [ ), }, { - value: LegendSizes.LARGE, + value: LegendSize.LARGE, inputDisplay: i18n.translate( 'visDefaultEditor.options.legendSizeSetting.legendSizeOptions.large', { @@ -57,7 +41,7 @@ const legendSizeOptions: Array<{ value: LegendSizes; inputDisplay: string }> = [ ), }, { - value: LegendSizes.EXTRA_LARGE, + value: LegendSize.EXTRA_LARGE, inputDisplay: i18n.translate( 'visDefaultEditor.options.legendSizeSetting.legendSizeOptions.extraLarge', { @@ -68,15 +52,17 @@ const legendSizeOptions: Array<{ value: LegendSizes; inputDisplay: string }> = [ ]; interface LegendSizeSettingsProps { - legendSize?: number; - onLegendSizeChange: (size?: number) => void; + legendSize?: LegendSize; + onLegendSizeChange: (size?: LegendSize) => void; isVerticalLegend: boolean; + showAutoOption: boolean; } export const LegendSizeSettings = ({ legendSize, onLegendSizeChange, isVerticalLegend, + showAutoOption, }: LegendSizeSettingsProps) => { useEffect(() => { if (legendSize && !isVerticalLegend) { @@ -85,16 +71,31 @@ export const LegendSizeSettings = ({ }, [isVerticalLegend, legendSize, onLegendSizeChange]); const onLegendSizeOptionChange = useCallback( - (option) => onLegendSizeChange(Number(option) || undefined), + (option) => onLegendSizeChange(option === DEFAULT_LEGEND_SIZE ? undefined : option), [onLegendSizeChange] ); + const options = showAutoOption + ? [ + { + value: LegendSize.AUTO, + inputDisplay: i18n.translate( + 'visDefaultEditor.options.legendSizeSetting.legendSizeOptions.auto', + { + defaultMessage: 'Auto', + } + ), + }, + ...legendSizeOptions, + ] + : legendSizeOptions; + const legendSizeSelect = ( diff --git a/src/plugins/vis_type_markdown/public/markdown_vis.ts b/src/plugins/vis_type_markdown/public/markdown_vis.ts index 7fcf9fb6311e676..a4b4010064e78d1 100644 --- a/src/plugins/vis_type_markdown/public/markdown_vis.ts +++ b/src/plugins/vis_type_markdown/public/markdown_vis.ts @@ -58,6 +58,8 @@ export const markdownVisDefinition: VisTypeDefinition = { options: { showTimePicker: false, showFilterBar: false, + showQueryBar: true, + showQueryInput: false, }, inspectorAdapters: {}, }; diff --git a/src/plugins/vis_types/heatmap/public/editor/components/heatmap.tsx b/src/plugins/vis_types/heatmap/public/editor/components/heatmap.tsx index f592ee3933c1cf7..3c06e65e2cff4cd 100644 --- a/src/plugins/vis_types/heatmap/public/editor/components/heatmap.tsx +++ b/src/plugins/vis_types/heatmap/public/editor/components/heatmap.tsx @@ -26,7 +26,7 @@ import { LegendSizeSettings, } from '@kbn/vis-default-editor-plugin/public'; import { colorSchemas } from '@kbn/charts-plugin/public'; -import { VisEditorOptionsProps } from '@kbn/visualizations-plugin/public'; +import { LegendSize, VisEditorOptionsProps } from '@kbn/visualizations-plugin/public'; import { HeatmapVisParams, HeatmapTypeProps, ValueAxis } from '../../types'; import { LabelsPanel } from './labels_panel'; import { legendPositions, scaleTypes } from '../collections'; @@ -42,6 +42,9 @@ const HeatmapOptions = (props: HeatmapOptionsProps) => { const isColorsNumberInvalid = stateParams.colorsNumber < 2 || stateParams.colorsNumber > 10; const [isColorRangesValid, setIsColorRangesValid] = useState(false); + const legendSize = stateParams.legendSize; + const [hadAutoLegendSize] = useState(() => legendSize === LegendSize.AUTO); + const setValueAxisScale = useCallback( (paramName: T, value: ValueAxis['scale'][T]) => setValue('valueAxes', [ @@ -91,12 +94,13 @@ const HeatmapOptions = (props: HeatmapOptionsProps) => { setValue={setValue} /> )} diff --git a/src/plugins/vis_types/heatmap/public/types.ts b/src/plugins/vis_types/heatmap/public/types.ts index 8301d246e9f6380..9d41a132f00b1ad 100644 --- a/src/plugins/vis_types/heatmap/public/types.ts +++ b/src/plugins/vis_types/heatmap/public/types.ts @@ -9,6 +9,7 @@ import { UiCounterMetricType } from '@kbn/analytics'; import type { Position } from '@elastic/charts'; import type { ChartsPluginSetup, Style, Labels, ColorSchemas } from '@kbn/charts-plugin/public'; import { Range } from '@kbn/expressions-plugin/public'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; export interface HeatmapTypeProps { showElasticChartsOptions?: boolean; @@ -23,7 +24,7 @@ export interface HeatmapVisParams { legendPosition: Position; truncateLegend?: boolean; maxLegendLines?: number; - legendSize?: number; + legendSize?: LegendSize; lastRangeIsRightOpen: boolean; percentageMode: boolean; valueAxes: ValueAxis[]; diff --git a/src/plugins/vis_types/metric/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_types/metric/public/__snapshots__/to_ast.test.ts.snap index 233e38874e6da63..ef6102571f32461 100644 --- a/src/plugins/vis_types/metric/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_types/metric/public/__snapshots__/to_ast.test.ts.snap @@ -59,6 +59,25 @@ Object { "type": "expression", }, ], + "labelFont": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "align": Array [ + "center", + ], + "size": Array [ + "14", + ], + }, + "function": "font", + "type": "function", + }, + ], + "type": "expression", + }, + ], "percentageMode": Array [ true, ], @@ -133,6 +152,25 @@ Object { "type": "expression", }, ], + "labelFont": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "align": Array [ + "center", + ], + "size": Array [ + "14", + ], + }, + "function": "font", + "type": "function", + }, + ], + "type": "expression", + }, + ], "showLabels": Array [ false, ], diff --git a/src/plugins/vis_types/metric/public/to_ast.ts b/src/plugins/vis_types/metric/public/to_ast.ts index 322ea561abeb42a..d206d046cde6abb 100644 --- a/src/plugins/vis_types/metric/public/to_ast.ts +++ b/src/plugins/vis_types/metric/public/to_ast.ts @@ -83,6 +83,8 @@ export const toExpressionAst: VisToExpressionAst = (vis, params) => { ) ); + metricVis.addArgument('labelFont', buildExpression(`font size="14" align="center"`)); + if (colorsRange && colorsRange.length > 1) { const stopsWithColors = getStopsWithColorsFromRanges(colorsRange, colorSchema, invertColors); const palette = buildExpressionFunction('palette', { diff --git a/src/plugins/vis_types/pie/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_types/pie/public/__snapshots__/to_ast.test.ts.snap index 904dff6ee119251..5b8bd613609f921 100644 --- a/src/plugins/vis_types/pie/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_types/pie/public/__snapshots__/to_ast.test.ts.snap @@ -93,6 +93,9 @@ Object { "legendPosition": Array [ "right", ], + "legendSize": Array [ + "large", + ], "metric": Array [ Object { "chain": Array [ diff --git a/src/plugins/vis_types/pie/public/editor/components/pie.tsx b/src/plugins/vis_types/pie/public/editor/components/pie.tsx index f1f335f186ffdb3..cd1e565861d783a 100644 --- a/src/plugins/vis_types/pie/public/editor/components/pie.tsx +++ b/src/plugins/vis_types/pie/public/editor/components/pie.tsx @@ -31,7 +31,7 @@ import { LongLegendOptions, LegendSizeSettings, } from '@kbn/vis-default-editor-plugin/public'; -import { VisEditorOptionsProps } from '@kbn/visualizations-plugin/public'; +import { LegendSize, VisEditorOptionsProps } from '@kbn/visualizations-plugin/public'; import { PartitionVisParams, LabelPositions, @@ -97,6 +97,9 @@ const PieOptions = (props: PieOptionsProps) => { const hasSplitChart = Boolean(aggs?.aggs?.find((agg) => agg.schema === 'split' && agg.enabled)); const segments = aggs?.aggs?.filter((agg) => agg.schema === 'segment' && agg.enabled) ?? []; + const legendSize = stateParams.legendSize; + const [hadAutoLegendSize] = useState(() => legendSize === LegendSize.AUTO); + const getLegendDisplay = useCallback( (isVisible: boolean) => (isVisible ? LegendDisplay.SHOW : LegendDisplay.HIDE), [] @@ -234,12 +237,13 @@ const PieOptions = (props: PieOptionsProps) => { setValue={setValue} /> )} diff --git a/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts b/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts index f8836f208d916bd..4c638689ca31073 100644 --- a/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts +++ b/src/plugins/vis_types/pie/public/sample_vis.test.mocks.ts @@ -7,6 +7,7 @@ */ import { LegendDisplay } from '@kbn/expression-partition-vis-plugin/common'; +import { LegendSize } from '@kbn/visualizations-plugin/common'; export const samplePieVis = { type: { @@ -142,6 +143,7 @@ export const samplePieVis = { addTooltip: true, legendDisplay: LegendDisplay.SHOW, legendPosition: 'right', + legendSize: LegendSize.LARGE, isDonut: true, labels: { show: true, diff --git a/src/plugins/vis_types/pie/public/to_ast.ts b/src/plugins/vis_types/pie/public/to_ast.ts index aaac3040d7bd311..7a131dbb76b9c24 100644 --- a/src/plugins/vis_types/pie/public/to_ast.ts +++ b/src/plugins/vis_types/pie/public/to_ast.ts @@ -62,14 +62,14 @@ export const toExpressionAst: VisToExpressionAst = async (vi addTooltip: vis.params.addTooltip, legendDisplay: vis.params.legendDisplay, legendPosition: vis.params.legendPosition, - nestedLegend: vis.params?.nestedLegend ?? false, + nestedLegend: vis.params.nestedLegend ?? false, truncateLegend: vis.params.truncateLegend, maxLegendLines: vis.params.maxLegendLines, legendSize: vis.params.legendSize, - distinctColors: vis.params?.distinctColors, + distinctColors: vis.params.distinctColors, isDonut: vis.params.isDonut ?? false, emptySizeRatio: vis.params.emptySizeRatio, - palette: preparePalette(vis.params?.palette), + palette: preparePalette(vis.params.palette), labels: prepareLabels(vis.params.labels), metric: schemas.metric.map(prepareDimension), buckets: schemas.segment?.map(prepareDimension), diff --git a/src/plugins/vis_types/table/public/components/table_vis_columns.tsx b/src/plugins/vis_types/table/public/components/table_vis_columns.tsx index 9aa30f95f180930..25bd6b0b9031cc6 100644 --- a/src/plugins/vis_types/table/public/components/table_vis_columns.tsx +++ b/src/plugins/vis_types/table/public/components/table_vis_columns.tsx @@ -35,7 +35,7 @@ export const createGridColumns = ( ) => { const onFilterClick = (data: FilterCellData, negate: boolean) => { fireEvent({ - name: 'filterBucket', + name: 'filter', data: { data: [ { diff --git a/src/plugins/vis_types/table/public/table_vis_fn.test.ts b/src/plugins/vis_types/table/public/table_vis_fn.test.ts index 98336d6cc67d4e6..87da839578117cf 100644 --- a/src/plugins/vis_types/table/public/table_vis_fn.test.ts +++ b/src/plugins/vis_types/table/public/table_vis_fn.test.ts @@ -79,6 +79,7 @@ describe('interpreter/functions#table', () => { logDatatable: (name: string, datatable: Datatable) => { loggedTable = datatable; }, + reset: () => {}, }, }, }; diff --git a/src/plugins/vis_types/timelion/public/timelion_vis_type.tsx b/src/plugins/vis_types/timelion/public/timelion_vis_type.tsx index f32a485ac2565df..f8d7415f6aefef3 100644 --- a/src/plugins/vis_types/timelion/public/timelion_vis_type.tsx +++ b/src/plugins/vis_types/timelion/public/timelion_vis_type.tsx @@ -66,8 +66,9 @@ export function getTimelionVisDefinition(dependencies: TimelionVisDependencies) }, options: { showIndexSelection: false, - showQueryBar: false, + showQueryBar: true, showFilterBar: false, + showQueryInput: false, }, requiresSearch: true, }; diff --git a/src/plugins/vis_types/timeseries/public/application/components/aggs/percentile_rank/percentile_rank.tsx b/src/plugins/vis_types/timeseries/public/application/components/aggs/percentile_rank/percentile_rank.tsx index fd689f917030baa..4252bb9f0f5aa40 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/aggs/percentile_rank/percentile_rank.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/aggs/percentile_rank/percentile_rank.tsx @@ -112,7 +112,7 @@ export const PercentileRankAgg = (props: PercentileRankAggProps) => { indexPattern={indexPattern} value={model.field ?? ''} onChange={(value) => - props.onChange({ + handleChange({ field: value?.[0], }) } diff --git a/src/plugins/vis_types/timeseries/public/application/components/timeseries_visualization.tsx b/src/plugins/vis_types/timeseries/public/application/components/timeseries_visualization.tsx index e29d47844950e30..181ea661b69f8ca 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/timeseries_visualization.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/timeseries_visualization.tsx @@ -125,7 +125,7 @@ function TimeseriesVisualization({ const data = getClickFilterData(points, tables, model); const event = { - name: 'filterBucket', + name: 'filter', data: { data, negate: false, diff --git a/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/math.js b/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/math.js index 1d6db3704b95bd9..d14757f3e229b3e 100644 --- a/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/math.js +++ b/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/math.js @@ -14,6 +14,7 @@ const percentileValueMatch = /\[([0-9\.]+)\]$/; import { startsWith, flatten, values, first, last } from 'lodash'; import { getDefaultDecoration, getSiblingAggValue, getSplits, mapEmptyToZero } from '../../helpers'; import { evaluate } from '@kbn/tinymath'; +import { TSVB_METRIC_TYPES } from '../../../../../common/enums'; export function mathAgg(resp, panel, series, meta, extractFields) { return (next) => async (results) => { @@ -42,7 +43,13 @@ export function mathAgg(resp, panel, series, meta, extractFields) { }); } else { const percentileMatch = v.field.match(percentileValueMatch); - const m = percentileMatch ? { ...metric, percent: percentileMatch[1] } : { ...metric }; + const m = percentileMatch + ? { + ...metric, + [metric.type === TSVB_METRIC_TYPES.PERCENTILE ? 'percent' : 'value']: + percentileMatch[1], + } + : { ...metric }; acc[v.name] = mapEmptyToZero(m, split.timeseries.buckets); } return acc; diff --git a/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/math.test.js b/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/math.test.js index 777929037f04fd0..3ff2ee0cba3536b 100644 --- a/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/math.test.js +++ b/src/plugins/vis_types/timeseries/server/lib/vis_data/response_processors/series/math.test.js @@ -108,6 +108,64 @@ describe('math(resp, panel, series)', () => { }); }); + test('works with percentiles and percentile rank', async () => { + series.metrics = [ + { + id: 'percentile_cpu', + type: 'percentile', + field: 'cpu', + percentiles: [{ value: 50, id: 'p50' }], + }, + { + id: 'rank_cpu', + type: 'percentile_rank', + field: 'cpu', + percentiles: [{ value: 500, id: 'p500' }], + }, + { + id: 'mathagg', + type: 'math', + script: 'divide(params.a, params.b)', + variables: [ + { name: 'a', field: 'percentile_cpu[50.0]' }, + { name: 'b', field: 'rank_cpu[500.0]' }, + ], + }, + ]; + resp.aggregations.test.buckets[0].timeseries.buckets[0].percentile_cpu = { + values: { '50.0': 0.25 }, + }; + resp.aggregations.test.buckets[0].timeseries.buckets[0].rank_cpu = { + values: { '500.0': 0.125 }, + }; + resp.aggregations.test.buckets[0].timeseries.buckets[1].percentile_cpu = { + values: { '50.0': 0.25 }, + }; + resp.aggregations.test.buckets[0].timeseries.buckets[1].rank_cpu = { + values: { '500.0': 0.25 }, + }; + + const next = await mathAgg(resp, panel, series)((results) => results); + const results = await stdMetric(resp, panel, series)(next)([]); + + expect(results).toHaveLength(1); + + expect(results[0]).toEqual({ + id: 'test╰┄►example-01', + label: 'example-01', + color: 'rgb(255, 0, 0)', + stack: false, + seriesId: 'test', + lines: { show: true, fill: 0, lineWidth: 1, steps: false }, + points: { show: true, radius: 1, lineWidth: 1 }, + bars: { fill: 0, lineWidth: 1, show: false }, + data: [ + [1, 2], + [2, 1], + ], + }); + }); + test('handles math even if there is a series agg', async () => { series.metrics.push({ id: 'myid', diff --git a/src/plugins/vis_types/vislib/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_types/vislib/public/__snapshots__/to_ast.test.ts.snap index 233940d97d38a5d..6d20088dbff32d9 100644 --- a/src/plugins/vis_types/vislib/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_types/vislib/public/__snapshots__/to_ast.test.ts.snap @@ -8,7 +8,7 @@ Object { "area", ], "visConfig": Array [ - "{\\"type\\":\\"area\\",\\"grid\\":{\\"categoryLines\\":false,\\"style\\":{\\"color\\":\\"#eee\\"}},\\"categoryAxes\\":[{\\"id\\":\\"CategoryAxis-1\\",\\"type\\":\\"category\\",\\"position\\":\\"bottom\\",\\"show\\":true,\\"style\\":{},\\"scale\\":{\\"type\\":\\"linear\\"},\\"labels\\":{\\"show\\":true,\\"truncate\\":100},\\"title\\":{}}],\\"valueAxes\\":[{\\"id\\":\\"ValueAxis-1\\",\\"name\\":\\"LeftAxis-1\\",\\"type\\":\\"value\\",\\"position\\":\\"left\\",\\"show\\":true,\\"style\\":{},\\"scale\\":{\\"type\\":\\"linear\\",\\"mode\\":\\"normal\\"},\\"labels\\":{\\"show\\":true,\\"rotate\\":0,\\"filter\\":false,\\"truncate\\":100},\\"title\\":{\\"text\\":\\"Sum of total_quantity\\"}}],\\"seriesParams\\":[{\\"show\\":\\"true\\",\\"type\\":\\"area\\",\\"mode\\":\\"stacked\\",\\"data\\":{\\"label\\":\\"Sum of total_quantity\\",\\"id\\":\\"1\\"},\\"drawLinesBetweenPoints\\":true,\\"showCircles\\":true,\\"circlesRadius\\":5,\\"interpolate\\":\\"linear\\",\\"valueAxis\\":\\"ValueAxis-1\\"}],\\"addTooltip\\":true,\\"addLegend\\":true,\\"legendPosition\\":\\"top\\",\\"times\\":[],\\"addTimeMarker\\":false,\\"truncateLegend\\":true,\\"maxLegendLines\\":1,\\"thresholdLine\\":{\\"show\\":false,\\"value\\":10,\\"width\\":1,\\"style\\":\\"full\\",\\"color\\":\\"#E7664C\\"},\\"palette\\":{\\"name\\":\\"default\\"},\\"labels\\":{},\\"dimensions\\":{\\"x\\":{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"date\\",\\"params\\":{\\"pattern\\":\\"HH:mm:ss.SSS\\"}},\\"params\\":{}},\\"y\\":[{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"number\\",\\"params\\":{\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}],\\"series\\":[{\\"accessor\\":2,\\"format\\":{\\"id\\":\\"terms\\",\\"params\\":{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\",\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}]}}", + "{\\"type\\":\\"area\\",\\"grid\\":{\\"categoryLines\\":false,\\"style\\":{\\"color\\":\\"#eee\\"}},\\"categoryAxes\\":[{\\"id\\":\\"CategoryAxis-1\\",\\"type\\":\\"category\\",\\"position\\":\\"bottom\\",\\"show\\":true,\\"style\\":{},\\"scale\\":{\\"type\\":\\"linear\\"},\\"labels\\":{\\"show\\":true,\\"truncate\\":100},\\"title\\":{}}],\\"valueAxes\\":[{\\"id\\":\\"ValueAxis-1\\",\\"name\\":\\"LeftAxis-1\\",\\"type\\":\\"value\\",\\"position\\":\\"left\\",\\"show\\":true,\\"style\\":{},\\"scale\\":{\\"type\\":\\"linear\\",\\"mode\\":\\"normal\\"},\\"labels\\":{\\"show\\":true,\\"rotate\\":0,\\"filter\\":false,\\"truncate\\":100},\\"title\\":{\\"text\\":\\"Sum of total_quantity\\"}}],\\"seriesParams\\":[{\\"show\\":\\"true\\",\\"type\\":\\"area\\",\\"mode\\":\\"stacked\\",\\"data\\":{\\"label\\":\\"Sum of total_quantity\\",\\"id\\":\\"1\\"},\\"drawLinesBetweenPoints\\":true,\\"showCircles\\":true,\\"circlesRadius\\":5,\\"interpolate\\":\\"linear\\",\\"valueAxis\\":\\"ValueAxis-1\\"}],\\"addTooltip\\":true,\\"addLegend\\":true,\\"legendPosition\\":\\"top\\",\\"legendSize\\":\\"small\\",\\"times\\":[],\\"addTimeMarker\\":false,\\"truncateLegend\\":true,\\"maxLegendLines\\":1,\\"thresholdLine\\":{\\"show\\":false,\\"value\\":10,\\"width\\":1,\\"style\\":\\"full\\",\\"color\\":\\"#E7664C\\"},\\"palette\\":{\\"name\\":\\"default\\"},\\"labels\\":{},\\"dimensions\\":{\\"x\\":{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"date\\",\\"params\\":{\\"pattern\\":\\"HH:mm:ss.SSS\\"}},\\"params\\":{}},\\"y\\":[{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"number\\",\\"params\\":{\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}],\\"series\\":[{\\"accessor\\":2,\\"format\\":{\\"id\\":\\"terms\\",\\"params\\":{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\",\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}]}}", ], }, "getArgument": [Function], diff --git a/src/plugins/vis_types/vislib/public/__snapshots__/to_ast_pie.test.ts.snap b/src/plugins/vis_types/vislib/public/__snapshots__/to_ast_pie.test.ts.snap index 1eedae99ffedb39..80e52d95be5c92c 100644 --- a/src/plugins/vis_types/vislib/public/__snapshots__/to_ast_pie.test.ts.snap +++ b/src/plugins/vis_types/vislib/public/__snapshots__/to_ast_pie.test.ts.snap @@ -5,7 +5,7 @@ Object { "addArgument": [Function], "arguments": Object { "visConfig": Array [ - "{\\"type\\":\\"pie\\",\\"addTooltip\\":true,\\"legendDisplay\\":\\"show\\",\\"legendPosition\\":\\"right\\",\\"isDonut\\":true,\\"labels\\":{\\"show\\":true,\\"values\\":true,\\"last_level\\":true,\\"truncate\\":100},\\"dimensions\\":{\\"metric\\":{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"number\\"},\\"params\\":{}},\\"buckets\\":[{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"terms\\",\\"params\\":{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\",\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}]}}", + "{\\"type\\":\\"pie\\",\\"addTooltip\\":true,\\"legendDisplay\\":\\"show\\",\\"legendPosition\\":\\"right\\",\\"legendSize\\":\\"large\\",\\"isDonut\\":true,\\"labels\\":{\\"show\\":true,\\"values\\":true,\\"last_level\\":true,\\"truncate\\":100},\\"dimensions\\":{\\"metric\\":{\\"accessor\\":0,\\"format\\":{\\"id\\":\\"number\\"},\\"params\\":{}},\\"buckets\\":[{\\"accessor\\":1,\\"format\\":{\\"id\\":\\"terms\\",\\"params\\":{\\"id\\":\\"string\\",\\"otherBucketLabel\\":\\"Other\\",\\"missingBucketLabel\\":\\"Missing\\",\\"parsedUrl\\":{\\"origin\\":\\"http://localhost:5801\\",\\"pathname\\":\\"/app/visualize\\",\\"basePath\\":\\"\\"}}},\\"params\\":{}}]}}", ], }, "getArgument": [Function], diff --git a/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.test.tsx b/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.test.tsx index 7f948917764df3a..5c4c4e3c2c1452e 100644 --- a/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.test.tsx +++ b/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.test.tsx @@ -201,7 +201,7 @@ describe('VisLegend Component', () => { }); expect(fireEvent).toHaveBeenCalledWith({ - name: 'filterBucket', + name: 'filter', data: { data: ['valuesA'], negate: false }, }); expect(fireEvent).toHaveBeenCalledTimes(1); @@ -216,7 +216,7 @@ describe('VisLegend Component', () => { }); expect(fireEvent).toHaveBeenCalledWith({ - name: 'filterBucket', + name: 'filter', data: { data: ['valuesA'], negate: true }, }); expect(fireEvent).toHaveBeenCalledTimes(1); diff --git a/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.tsx b/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.tsx index 577a76dd8445425..fedeb03cdde281f 100644 --- a/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.tsx +++ b/src/plugins/vis_types/vislib/public/vislib/components/legend/legend.tsx @@ -87,7 +87,7 @@ export class VisLegend extends PureComponent { filter = ({ values: data }: LegendItem, negate: boolean) => { this.props.fireEvent({ - name: 'filterBucket', + name: 'filter', data: { data, negate, diff --git a/src/plugins/vis_types/vislib/public/vislib/lib/handler.js b/src/plugins/vis_types/vislib/public/vislib/lib/handler.js index fe8388e025b94b2..177febfb2812c5a 100644 --- a/src/plugins/vis_types/vislib/public/vislib/lib/handler.js +++ b/src/plugins/vis_types/vislib/public/vislib/lib/handler.js @@ -95,7 +95,7 @@ export class Handler { }); case 'click': return self.vis.emit(eventType, { - name: 'filterBucket', + name: 'filter', data: eventPayload, }); } diff --git a/src/plugins/vis_types/vislib/public/vislib/percentage_mode_transform.ts b/src/plugins/vis_types/vislib/public/vislib/percentage_mode_transform.ts index 2aa33dc4a545992..c93784ec9c9f864 100644 --- a/src/plugins/vis_types/vislib/public/vislib/percentage_mode_transform.ts +++ b/src/plugins/vis_types/vislib/public/vislib/percentage_mode_transform.ts @@ -6,8 +6,6 @@ * Side Public License, v 1. */ -// @ts-ignore -import numeral from '@elastic/numeral'; import { getFormatService } from '../services'; export function getValueForPercentageMode(value: string | number, percentageFormatPattern: string) { diff --git a/src/plugins/vis_types/xy/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_types/xy/public/__snapshots__/to_ast.test.ts.snap index 7ee1b0d2b205354..048b07dbf34ed9c 100644 --- a/src/plugins/vis_types/xy/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_types/xy/public/__snapshots__/to_ast.test.ts.snap @@ -32,6 +32,9 @@ Object { "legendPosition": Array [ "top", ], + "legendSize": Array [ + "small", + ], "maxLegendLines": Array [ 1, ], diff --git a/src/plugins/vis_types/xy/public/editor/components/options/point_series/point_series.tsx b/src/plugins/vis_types/xy/public/editor/components/options/point_series/point_series.tsx index 15b5adf00b41f81..c12eae1b20b8e34 100644 --- a/src/plugins/vis_types/xy/public/editor/components/options/point_series/point_series.tsx +++ b/src/plugins/vis_types/xy/public/editor/components/options/point_series/point_series.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { EuiPanel, EuiTitle, EuiSpacer } from '@elastic/eui'; import { Position } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; @@ -20,6 +20,7 @@ import { } from '@kbn/vis-default-editor-plugin/public'; import { BUCKET_TYPES } from '@kbn/data-plugin/public'; +import { LegendSize } from '@kbn/visualizations-plugin/public'; import { VisParams } from '../../../../types'; import { GridPanel } from './grid_panel'; import { ThresholdPanel } from './threshold_panel'; @@ -41,6 +42,10 @@ export function PointSeriesOptions(props: ValidationVisOptionsProps) [stateParams.seriesParams, aggs.aggs] ); + const legendSize = stateParams.legendSize; + + const [hadAutoLegendSize] = useState(() => legendSize === LegendSize.AUTO); + const handleLegendSizeChange = useCallback((size) => setValue('legendSize', size), [setValue]); return ( @@ -64,12 +69,13 @@ export function PointSeriesOptions(props: ValidationVisOptionsProps) setValue={setValue} /> {vis.data.aggs!.aggs.some( diff --git a/src/plugins/vis_types/xy/public/expression_functions/xy_vis_fn.ts b/src/plugins/vis_types/xy/public/expression_functions/xy_vis_fn.ts index 96c4ab112caf105..08319e8e9a11b18 100644 --- a/src/plugins/vis_types/xy/public/expression_functions/xy_vis_fn.ts +++ b/src/plugins/vis_types/xy/public/expression_functions/xy_vis_fn.ts @@ -13,7 +13,12 @@ import type { Datatable, Render, } from '@kbn/expressions-plugin/common'; -import { prepareLogTable, Dimension } from '@kbn/visualizations-plugin/public'; +import { + prepareLogTable, + Dimension, + DEFAULT_LEGEND_SIZE, + LegendSize, +} from '@kbn/visualizations-plugin/public'; import type { ChartType } from '../../common'; import type { VisParams, XYVisConfig } from '../types'; @@ -73,10 +78,19 @@ export const visTypeXyVisFn = (): VisTypeXyExpressionFunctionDefinition => ({ }), }, legendSize: { - types: ['number'], + types: ['string'], + default: DEFAULT_LEGEND_SIZE, help: i18n.translate('visTypeXy.function.args.args.legendSize.help', { - defaultMessage: 'Specifies the legend size in pixels.', + defaultMessage: 'Specifies the legend size.', }), + options: [ + LegendSize.AUTO, + LegendSize.SMALL, + LegendSize.MEDIUM, + LegendSize.LARGE, + LegendSize.EXTRA_LARGE, + ], + strict: true, }, addLegend: { types: ['boolean'], diff --git a/src/plugins/vis_types/xy/public/sample_vis.test.mocks.ts b/src/plugins/vis_types/xy/public/sample_vis.test.mocks.ts index 436a284b1657aee..3c1d87d2efc3c00 100644 --- a/src/plugins/vis_types/xy/public/sample_vis.test.mocks.ts +++ b/src/plugins/vis_types/xy/public/sample_vis.test.mocks.ts @@ -5,6 +5,9 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ + +import { LegendSize } from '@kbn/visualizations-plugin/common'; + export const sampleAreaVis = { type: { name: 'area', @@ -282,6 +285,7 @@ export const sampleAreaVis = { addTooltip: true, addLegend: true, legendPosition: 'top', + legendSize: LegendSize.SMALL, times: [], addTimeMarker: false, truncateLegend: true, diff --git a/src/plugins/vis_types/xy/public/types/param.ts b/src/plugins/vis_types/xy/public/types/param.ts index 708eb1cbdd19666..a491efad97fcb89 100644 --- a/src/plugins/vis_types/xy/public/types/param.ts +++ b/src/plugins/vis_types/xy/public/types/param.ts @@ -15,6 +15,7 @@ import type { FakeParams, HistogramParams, DateHistogramParams, + LegendSize, } from '@kbn/visualizations-plugin/public'; import type { ChartType, XyVisType } from '../../common'; import type { @@ -124,7 +125,7 @@ export interface VisParams { addTimeMarker: boolean; truncateLegend: boolean; maxLegendLines: number; - legendSize?: number; + legendSize?: LegendSize; categoryAxes: CategoryAxis[]; orderBucketsBySum?: boolean; labels: Labels; @@ -165,7 +166,7 @@ export interface XYVisConfig { addTimeMarker: boolean; truncateLegend: boolean; maxLegendLines: number; - legendSize?: number; + legendSize?: LegendSize; orderBucketsBySum?: boolean; labels: ExpressionValueLabel; thresholdLine: ExpressionValueThresholdLine; diff --git a/src/plugins/vis_types/xy/public/vis_component.tsx b/src/plugins/vis_types/xy/public/vis_component.tsx index 7c0636ab284fbae..a744841601a67b1 100644 --- a/src/plugins/vis_types/xy/public/vis_component.tsx +++ b/src/plugins/vis_types/xy/public/vis_component.tsx @@ -33,7 +33,11 @@ import { useActiveCursor, } from '@kbn/charts-plugin/public'; import { Datatable, IInterpreterRenderHandlers } from '@kbn/expressions-plugin/public'; -import type { PersistedState } from '@kbn/visualizations-plugin/public'; +import { + DEFAULT_LEGEND_SIZE, + LegendSizeToPixels, + PersistedState, +} from '@kbn/visualizations-plugin/public'; import { VisParams } from './types'; import { getAdjustedDomain, @@ -361,7 +365,7 @@ const VisComponent = (props: VisComponentProps) => { tooltip: { visible: syncTooltips, placement: Placement.Right }, }} legendPosition={legendPosition} - legendSize={visParams.legendSize} + legendSize={LegendSizeToPixels[visParams.legendSize ?? DEFAULT_LEGEND_SIZE]} xDomain={xDomain} adjustedXDomain={adjustedXDomain} legendColorPicker={legendColorPicker} diff --git a/src/plugins/visualizations/common/constants.ts b/src/plugins/visualizations/common/constants.ts index 0b840c8ff13fcd3..ea695e6bdca029c 100644 --- a/src/plugins/visualizations/common/constants.ts +++ b/src/plugins/visualizations/common/constants.ts @@ -26,3 +26,21 @@ export const VisualizeConstants = { EDIT_BY_VALUE_PATH: '/edit_by_value', APP_ID: 'visualize', }; + +export enum LegendSize { + AUTO = 'auto', + SMALL = 'small', + MEDIUM = 'medium', + LARGE = 'large', + EXTRA_LARGE = 'xlarge', +} + +export const LegendSizeToPixels = { + [LegendSize.AUTO]: undefined, + [LegendSize.SMALL]: 80, + [LegendSize.MEDIUM]: 130, + [LegendSize.LARGE]: 180, + [LegendSize.EXTRA_LARGE]: 230, +} as const; + +export const DEFAULT_LEGEND_SIZE = LegendSize.MEDIUM; diff --git a/src/plugins/visualizations/common/index.ts b/src/plugins/visualizations/common/index.ts index d784fcfd09eb981..1dd9a0e90477cb8 100644 --- a/src/plugins/visualizations/common/index.ts +++ b/src/plugins/visualizations/common/index.ts @@ -13,3 +13,4 @@ export * from './types'; export * from './utils'; export * from './expression_functions'; +export { LegendSize, LegendSizeToPixels, DEFAULT_LEGEND_SIZE } from './constants'; diff --git a/src/plugins/visualizations/common/utils/accessors.ts b/src/plugins/visualizations/common/utils/accessors.ts index 83ec3aa802ac3a4..e8146906b036a3d 100644 --- a/src/plugins/visualizations/common/utils/accessors.ts +++ b/src/plugins/visualizations/common/utils/accessors.ts @@ -7,6 +7,7 @@ */ import { i18n } from '@kbn/i18n'; import { Datatable, DatatableColumn } from '@kbn/expressions-plugin/common'; +import { FieldFormatParams, SerializedFieldFormat } from '@kbn/field-formats-plugin/common/types'; import { ExpressionValueVisDimension } from '../expression_functions'; const getAccessorByIndex = (accessor: number, columns: Datatable['columns']) => @@ -66,11 +67,12 @@ export function getAccessor(dimension: string | ExpressionValueVisDimension) { export function getFormatByAccessor( dimension: string | ExpressionValueVisDimension, - columns: DatatableColumn[] + columns: DatatableColumn[], + defaultColumnFormat?: SerializedFieldFormat ) { return typeof dimension === 'string' - ? getColumnByAccessor(dimension, columns)?.meta.params - : dimension.format; + ? getColumnByAccessor(dimension, columns)?.meta.params || defaultColumnFormat + : dimension.format || defaultColumnFormat; } export const getColumnByAccessor = ( diff --git a/src/plugins/visualizations/public/index.ts b/src/plugins/visualizations/public/index.ts index 22217e9de9abeb7..67b13c8236708cb 100644 --- a/src/plugins/visualizations/public/index.ts +++ b/src/plugins/visualizations/public/index.ts @@ -56,6 +56,9 @@ export { VISUALIZE_ENABLE_LABS_SETTING, SAVED_OBJECTS_LIMIT_SETTING, SAVED_OBJECTS_PER_PAGE_SETTING, + LegendSize, + LegendSizeToPixels, + DEFAULT_LEGEND_SIZE, } from '../common/constants'; export type { SavedVisState, VisParams, Dimension } from '../common'; export { prepareLogTable } from '../common'; diff --git a/src/plugins/visualizations/public/vis_types/base_vis_type.ts b/src/plugins/visualizations/public/vis_types/base_vis_type.ts index 80295e5af2e4017..bb197e219f439cb 100644 --- a/src/plugins/visualizations/public/vis_types/base_vis_type.ts +++ b/src/plugins/visualizations/public/vis_types/base_vis_type.ts @@ -18,6 +18,7 @@ const defaultOptions: VisTypeOptions = { showQueryBar: true, showFilterBar: true, showIndexSelection: true, + showQueryInput: true, hierarchicalData: false, // we should get rid of this i guess ? }; diff --git a/src/plugins/visualizations/public/vis_types/types.ts b/src/plugins/visualizations/public/vis_types/types.ts index 0e7e44b6ea38e47..383a238621e1e7b 100644 --- a/src/plugins/visualizations/public/vis_types/types.ts +++ b/src/plugins/visualizations/public/vis_types/types.ts @@ -20,6 +20,7 @@ export interface VisTypeOptions { showQueryBar: boolean; showFilterBar: boolean; showIndexSelection: boolean; + showQueryInput: boolean; hierarchicalData: boolean; } diff --git a/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx b/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx index a6c1710afbed8dd..e42ee1d0cd6c03b 100644 --- a/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx +++ b/src/plugins/visualizations/public/visualize_app/components/visualize_top_nav.tsx @@ -161,7 +161,8 @@ const TopNav = ({ return vis.type.options.showTimePicker && hasTimeField; }; const showFilterBar = vis.type.options.showFilterBar; - const showQueryInput = vis.type.requiresSearch && vis.type.options.showQueryBar; + const showQueryInput = + vis.type.requiresSearch && vis.type.options.showQueryBar && vis.type.options.showQueryInput; useEffect(() => { return () => { diff --git a/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.ts b/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.ts index d92810743bed497..1d8a00ab2e33b9b 100644 --- a/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.ts +++ b/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.ts @@ -26,6 +26,7 @@ import { commonAddDropLastBucketIntoTSVBModel714Above, commonRemoveMarkdownLessFromTSVB, commonUpdatePieVisApi, + commonPreserveOldLegendSizeDefault, } from '../migrations/visualization_common_migrations'; import { SerializedVis } from '../../common'; @@ -97,6 +98,11 @@ const byValueUpdatePieVisApi = (state: SerializableRecord) => ({ savedVis: commonUpdatePieVisApi(state.savedVis), }); +const byValuePreserveOldLegendSizeDefault = (state: SerializableRecord) => ({ + ...state, + savedVis: commonPreserveOldLegendSizeDefault(state.savedVis), +}); + const getEmbeddedVisualizationSearchSourceMigrations = ( searchSourceMigrations: MigrateFunctionsObject ) => @@ -144,6 +150,7 @@ export const makeVisualizeEmbeddableFactory = '7.17.0': (state) => flow(byValueAddDropLastBucketIntoTSVBModel714Above)(state), '8.0.0': (state) => flow(byValueRemoveMarkdownLessFromTSVB)(state), '8.1.0': (state) => flow(byValueUpdatePieVisApi)(state), + '8.3.0': (state) => flow(byValuePreserveOldLegendSizeDefault)(state), } ), }; diff --git a/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts index aec452e356abe5c..57d814282288255 100644 --- a/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_common_migrations.ts @@ -215,3 +215,34 @@ export const commonUpdatePieVisApi = (visState: any) => { return visState; }; + +export const commonPreserveOldLegendSizeDefault = (visState: any) => { + const visualizationTypesWithLegends = [ + 'pie', + 'area', + 'histogram', + 'horizontal_bar', + 'line', + 'heatmap', + ]; + + const pixelsToLegendSize: Record = { + undefined: 'auto', + '80': 'small', + '130': 'medium', + '180': 'large', + '230': 'xlarge', + }; + + if (visualizationTypesWithLegends.includes(visState?.type)) { + return { + ...visState, + params: { + ...visState.params, + legendSize: pixelsToLegendSize[visState.params?.legendSize], + }, + }; + } + + return visState; +}; diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts index 99dbf548e6f4449..626dc14e053969a 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.test.ts @@ -2470,6 +2470,31 @@ describe('migration visualization', () => { }); }); + it('should not apply search source migrations within visualization when searchSourceJSON is not an object', () => { + const visualizationDoc = { + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: '5', + }, + }, + } as SavedObjectUnsanitizedDoc; + + const versionToTest = '1.2.4'; + const visMigrations = getAllMigrations({ + [versionToTest]: (state) => ({ ...state, migrated: true }), + }); + + expect( + visMigrations[versionToTest](visualizationDoc, {} as SavedObjectMigrationContext) + ).toEqual({ + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: '5', + }, + }, + }); + }); + describe('8.1.0 pie - labels and addLegend migration', () => { const getDoc = (addLegend: boolean, lastLevel: boolean = false) => ({ attributes: { @@ -2539,4 +2564,63 @@ describe('migration visualization', () => { expect(otherParams.addLegend).toBeUndefined(); }); }); + + describe('8.3.0 - preserves default legend size for existing visualizations', () => { + const getDoc = (type: string, legendSize: number | undefined) => ({ + attributes: { + title: 'Some Vis with a Legend', + description: '', + visState: JSON.stringify({ + type, + title: 'Pie vis', + params: { + legendSize, + }, + }), + }, + }); + const migrate = (doc: any) => + visualizationSavedObjectTypeMigrations['8.3.0']( + doc as Parameters[0], + savedObjectMigrationContext + ); + + const autoLegendSize = 'auto'; + const largeLegendSize = 'large'; + const largeLegendSizePx = 180; + + test.each([ + ['pie', undefined, autoLegendSize], + ['area', undefined, autoLegendSize], + ['histogram', undefined, autoLegendSize], + ['horizontal_bar', undefined, autoLegendSize], + ['line', undefined, autoLegendSize], + ['heatmap', undefined, autoLegendSize], + ['pie', largeLegendSizePx, largeLegendSize], + ['area', largeLegendSizePx, largeLegendSize], + ['histogram', largeLegendSizePx, largeLegendSize], + ['horizontal_bar', largeLegendSizePx, largeLegendSize], + ['line', largeLegendSizePx, largeLegendSize], + ['heatmap', largeLegendSizePx, largeLegendSize], + ])( + 'given a %s visualization with current legend size of %s -- sets legend size to %s', + ( + visualizationType: string, + currentLegendSize: number | undefined, + expectedLegendSize: string + ) => { + const visState = JSON.parse( + migrate(getDoc(visualizationType, currentLegendSize)).attributes.visState + ); + + expect(visState.params.legendSize).toBe(expectedLegendSize); + } + ); + + test.each(['metric', 'gauge', 'table'])('leaves visualization without legend alone: %s', () => { + const visState = JSON.parse(migrate(getDoc('table', undefined)).attributes.visState); + + expect(visState.params.legendSize).toBeUndefined(); + }); + }); }); diff --git a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts index 4b729afa62307c5..bb2d68cfd35d915 100644 --- a/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts +++ b/src/plugins/visualizations/server/migrations/visualization_saved_object_migrations.ts @@ -11,7 +11,11 @@ import type { SavedObjectMigrationFn, SavedObjectMigrationMap } from '@kbn/core/ import { mergeSavedObjectMigrationMaps } from '@kbn/core/server'; import { MigrateFunctionsObject, MigrateFunction } from '@kbn/kibana-utils-plugin/common'; -import { DEFAULT_QUERY_LANGUAGE, SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import { + DEFAULT_QUERY_LANGUAGE, + isSerializedSearchSource, + SerializedSearchSourceFields, +} from '@kbn/data-plugin/common'; import { DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/common'; import { commonAddSupportOfDualIndexSelectionModeInTSVB, @@ -24,6 +28,7 @@ import { commonAddDropLastBucketIntoTSVBModel714Above, commonRemoveMarkdownLessFromTSVB, commonUpdatePieVisApi, + commonPreserveOldLegendSizeDefault, } from './visualization_common_migrations'; import { VisualizationSavedObjectAttributes } from '../../common'; @@ -1154,6 +1159,30 @@ export const updatePieVisApi: SavedObjectMigrationFn = (doc) => { return doc; }; +const preserveOldLegendSizeDefault: SavedObjectMigrationFn = (doc) => { + const visStateJSON = get(doc, 'attributes.visState'); + let visState; + + if (visStateJSON) { + try { + visState = JSON.parse(visStateJSON); + } catch (e) { + // Let it go, the data is invalid and we'll leave it as is + } + + const newVisState = commonPreserveOldLegendSizeDefault(visState); + return { + ...doc, + attributes: { + ...doc.attributes, + visState: JSON.stringify(newVisState), + }, + }; + } + + return doc; +}; + const visualizationSavedObjectTypeMigrations = { /** * We need to have this migration twice, once with a version prior to 7.0.0 once with a version @@ -1210,32 +1239,37 @@ const visualizationSavedObjectTypeMigrations = { '7.17.0': flow(addDropLastBucketIntoTSVBModel714Above), '8.0.0': flow(removeMarkdownLessFromTSVB), '8.1.0': flow(updatePieVisApi), + '8.3.0': preserveOldLegendSizeDefault, }; /** * This creates a migration map that applies search source migrations to legacy visualization SOs */ -const getVisualizationSearchSourceMigrations = (searchSourceMigrations: MigrateFunctionsObject) => +const getVisualizationSearchSourceMigrations = ( + searchSourceMigrations: MigrateFunctionsObject +): MigrateFunctionsObject => mapValues( searchSourceMigrations, (migrate: MigrateFunction): MigrateFunction => (state) => { - const _state = state as unknown as { attributes: VisualizationSavedObjectAttributes }; - - const parsedSearchSourceJSON = _state.attributes.kibanaSavedObjectMeta.searchSourceJSON; - - if (!parsedSearchSourceJSON) return _state; - - return { - ..._state, - attributes: { - ..._state.attributes, - kibanaSavedObjectMeta: { - ..._state.attributes.kibanaSavedObjectMeta, - searchSourceJSON: JSON.stringify(migrate(JSON.parse(parsedSearchSourceJSON))), + const _state = state as { attributes: VisualizationSavedObjectAttributes }; + + const parsedSearchSourceJSON = JSON.parse( + _state.attributes.kibanaSavedObjectMeta.searchSourceJSON + ); + if (isSerializedSearchSource(parsedSearchSourceJSON)) { + return { + ..._state, + attributes: { + ..._state.attributes, + kibanaSavedObjectMeta: { + ..._state.attributes.kibanaSavedObjectMeta, + searchSourceJSON: JSON.stringify(migrate(parsedSearchSourceJSON)), + }, }, - }, - }; + }; + } + return _state; } ); @@ -1244,7 +1278,5 @@ export const getAllMigrations = ( ): SavedObjectMigrationMap => mergeSavedObjectMigrationMaps( visualizationSavedObjectTypeMigrations, - getVisualizationSearchSourceMigrations( - searchSourceMigrations - ) as unknown as SavedObjectMigrationMap + getVisualizationSearchSourceMigrations(searchSourceMigrations) as SavedObjectMigrationMap ); diff --git a/src/setup_node_env/ensure_node_preserve_symlinks.js b/src/setup_node_env/ensure_node_preserve_symlinks.js index 38995642036225b..5ec286801bdc4b4 100644 --- a/src/setup_node_env/ensure_node_preserve_symlinks.js +++ b/src/setup_node_env/ensure_node_preserve_symlinks.js @@ -89,10 +89,18 @@ } if (spawnResult.signal !== null) { - return 128 + spawnResult.signal; + console.log( + 'ensure_node_preserve_symlinks wrapper: process exitted with signal', + spawnResult.signal + ); + return 1; } if (spawnResult.error) { + console.log( + 'ensure_node_preserve_symlinks wrapper: process exitted with error', + spawnResult.error + ); return 1; } diff --git a/test/accessibility/apps/discover.ts b/test/accessibility/apps/discover.ts index 867e146e64ca330..8ce28d38e3b56f7 100644 --- a/test/accessibility/apps/discover.ts +++ b/test/accessibility/apps/discover.ts @@ -11,9 +11,12 @@ import { FtrProviderContext } from '../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'discover', 'header', 'share', 'timePicker']); const a11y = getService('a11y'); + const savedQueryManagementComponent = getService('savedQueryManagementComponent'); const inspector = getService('inspector'); const testSubjects = getService('testSubjects'); const TEST_COLUMN_NAMES = ['dayOfWeek', 'DestWeather']; + const toasts = getService('toasts'); + const browser = getService('browser'); describe('Discover a11y tests', () => { before(async () => { @@ -93,11 +96,97 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('a11y test on saved queries list panel', async () => { + await savedQueryManagementComponent.loadSavedQuery('test'); await PageObjects.discover.clickSavedQueriesPopOver(); - await testSubjects.moveMouseTo( - 'saved-query-list-item load-saved-query-test-button saved-query-list-item-selected saved-query-list-item-selected' - ); - await testSubjects.find('delete-saved-query-test-button'); + await testSubjects.click('saved-query-management-load-button'); + await savedQueryManagementComponent.deleteSavedQuery('test'); + await a11y.testAppSnapshot(); + }); + + // adding a11y tests for the new data grid + it('a11y test on single document view', async () => { + await testSubjects.click('docTableExpandToggleColumn'); + await PageObjects.discover.clickDocViewerTab(0); + await a11y.testAppSnapshot(); + }); + + it('a11y test on JSON view of the document', async () => { + await PageObjects.discover.clickDocViewerTab(1); + await a11y.testAppSnapshot(); + }); + + it('a11y test for actions on a field', async () => { + await PageObjects.discover.clickDocViewerTab(0); + await testSubjects.click('openFieldActionsButton-Cancelled'); + await a11y.testAppSnapshot(); + }); + + it('a11y test for data-grid table with columns', async () => { + await testSubjects.click('toggleColumnButton-Cancelled'); + await testSubjects.click('openFieldActionsButton-Carrier'); + await testSubjects.click('toggleColumnButton-Carrier'); + await testSubjects.click('euiFlyoutCloseButton'); + await toasts.dismissAllToasts(); + await a11y.testAppSnapshot(); + }); + + it('a11y test for data-grid actions on columns', async () => { + await testSubjects.click('dataGridHeaderCellActionButton-Carrier'); + await a11y.testAppSnapshot(); + }); + + it('a11y test for chart options panel', async () => { + await testSubjects.click('discoverChartOptionsToggle'); + await a11y.testAppSnapshot(); + }); + + it('a11y test for data grid with hidden chart', async () => { + await testSubjects.click('discoverChartToggle'); + await a11y.testAppSnapshot(); + await testSubjects.click('discoverChartOptionsToggle'); + await testSubjects.click('discoverChartToggle'); + }); + + it('a11y test for time interval panel', async () => { + await testSubjects.click('discoverChartOptionsToggle'); + await testSubjects.click('discoverTimeIntervalPanel'); + await a11y.testAppSnapshot(); + await testSubjects.click('contextMenuPanelTitleButton'); + await testSubjects.click('discoverChartOptionsToggle'); + }); + + // https://github.com/elastic/eui/issues/5900 + it.skip('a11y test for data grid sort panel', async () => { + await testSubjects.click('dataGridColumnSortingButton'); + await a11y.testAppSnapshot(); + await browser.pressKeys(browser.keys.ESCAPE); + }); + + it('a11y test for setting row height for display panel', async () => { + await testSubjects.click('dataGridDisplaySelectorPopover'); + await a11y.testAppSnapshot(); + await browser.pressKeys(browser.keys.ESCAPE); + }); + + it('a11y test for data grid in full screen', async () => { + await testSubjects.click('dataGridFullScreenButton'); + await a11y.testAppSnapshot(); + await browser.pressKeys(browser.keys.ESCAPE); + }); + + it('a11y test for field statistics data grid view', async () => { + await PageObjects.discover.clickViewModeFieldStatsButton(); + await a11y.testAppSnapshot(); + }); + + it('a11y test for data grid with collapsed side bar', async () => { + await PageObjects.discover.closeSidebar(); + await a11y.testAppSnapshot(); + await PageObjects.discover.toggleSidebarCollapse(); + }); + + it('a11y test for adding a field from side bar', async () => { + await testSubjects.click('indexPattern-add-field_btn'); await a11y.testAppSnapshot(); }); }); diff --git a/test/accessibility/apps/filter_panel.ts b/test/accessibility/apps/filter_panel.ts index deb1e9512cd8161..b479c62f4897573 100644 --- a/test/accessibility/apps/filter_panel.ts +++ b/test/accessibility/apps/filter_panel.ts @@ -43,38 +43,47 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // the following tests filter panel options which changes UI it('a11y test on filter panel options panel', async () => { await filterBar.addFilter('DestCountry', 'is', 'AU'); - await testSubjects.click('showFilterActions'); + await testSubjects.click('showQueryBarMenu'); await a11y.testAppSnapshot(); }); it('a11y test on disable all filter options view', async () => { - await testSubjects.click('disableAllFilters'); + await testSubjects.click('filter-sets-applyToAllFilters'); + await testSubjects.click('filter-sets-disableAllFilters'); await a11y.testAppSnapshot(); }); - it('a11y test on pin filters view', async () => { - await testSubjects.click('showFilterActions'); - await testSubjects.click('enableAllFilters'); - await testSubjects.click('showFilterActions'); - await testSubjects.click('pinAllFilters'); + it('a11y test on enable all filters view', async () => { + await testSubjects.click('showQueryBarMenu'); + await testSubjects.click('filter-sets-applyToAllFilters'); + await testSubjects.click('filter-sets-enableAllFilters'); + await a11y.testAppSnapshot(); + }); + + it('a11y test on pin all filters view', async () => { + await testSubjects.click('showQueryBarMenu'); + await testSubjects.click('filter-sets-applyToAllFilters'); + await testSubjects.click('filter-sets-pinAllFilters'); await a11y.testAppSnapshot(); }); it('a11y test on unpin all filters view', async () => { - await testSubjects.click('showFilterActions'); - await testSubjects.click('unpinAllFilters'); + await testSubjects.click('showQueryBarMenu'); + await testSubjects.click('filter-sets-applyToAllFilters'); + await testSubjects.click('filter-sets-unpinAllFilters'); await a11y.testAppSnapshot(); }); it('a11y test on invert inclusion of all filters view', async () => { - await testSubjects.click('showFilterActions'); - await testSubjects.click('invertInclusionAllFilters'); + await testSubjects.click('showQueryBarMenu'); + await testSubjects.click('filter-sets-applyToAllFilters'); + await testSubjects.click('filter-sets-invertAllFilters'); await a11y.testAppSnapshot(); }); it('a11y test on remove all filtes view', async () => { - await testSubjects.click('showFilterActions'); - await testSubjects.click('removeAllFilters'); + await testSubjects.click('showQueryBarMenu'); + await testSubjects.click('filter-sets-removeAllFilters'); await a11y.testAppSnapshot(); }); }); diff --git a/test/analytics/__fixtures__/plugins/analytics_ftr_helpers/public/custom_shipper.ts b/test/analytics/__fixtures__/plugins/analytics_ftr_helpers/public/custom_shipper.ts index ad45ba871f2c7eb..97bf37749c2561b 100644 --- a/test/analytics/__fixtures__/plugins/analytics_ftr_helpers/public/custom_shipper.ts +++ b/test/analytics/__fixtures__/plugins/analytics_ftr_helpers/public/custom_shipper.ts @@ -7,14 +7,21 @@ */ import { Subject } from 'rxjs'; +import type { AnalyticsClientInitContext } from '@kbn/analytics-client'; import type { Event, IShipper } from '@kbn/core/public'; export class CustomShipper implements IShipper { public static shipperName = 'FTR-helpers-shipper'; - constructor(private readonly events$: Subject) {} + constructor( + private readonly events$: Subject, + private readonly initContext: AnalyticsClientInitContext + ) {} public reportEvents(events: Event[]) { + this.initContext.logger.info( + `Reporting ${events.length} events to ${CustomShipper.shipperName}: ${JSON.stringify(events)}` + ); events.forEach((event) => { this.events$.next(event); }); diff --git a/test/analytics/__fixtures__/plugins/analytics_ftr_helpers/server/custom_shipper.ts b/test/analytics/__fixtures__/plugins/analytics_ftr_helpers/server/custom_shipper.ts index ed63f9a8db02f1b..c76f30c94572e03 100644 --- a/test/analytics/__fixtures__/plugins/analytics_ftr_helpers/server/custom_shipper.ts +++ b/test/analytics/__fixtures__/plugins/analytics_ftr_helpers/server/custom_shipper.ts @@ -7,14 +7,21 @@ */ import { Subject } from 'rxjs'; +import type { AnalyticsClientInitContext } from '@kbn/analytics-client'; import type { IShipper, Event } from '@kbn/core/server'; export class CustomShipper implements IShipper { public static shipperName = 'FTR-helpers-shipper'; - constructor(private readonly events$: Subject) {} + constructor( + private readonly events$: Subject, + private readonly initContext: AnalyticsClientInitContext + ) {} public reportEvents(events: Event[]) { + this.initContext.logger.info( + `Reporting ${events.length} events to ${CustomShipper.shipperName}: ${JSON.stringify(events)}` + ); events.forEach((event) => { this.events$.next(event); }); diff --git a/test/analytics/config.ts b/test/analytics/config.ts index 9dee422762e151d..ecb9792b0dff1e4 100644 --- a/test/analytics/config.ts +++ b/test/analytics/config.ts @@ -34,7 +34,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.get('kbnTestServer'), serverArgs: [ ...functionalConfig.get('kbnTestServer.serverArgs'), - // Disabling telemetry so it doesn't call opt-in before the tests run. + // Disabling telemetry, so it doesn't call opt-in before the tests run. '--telemetry.enabled=false', `--plugin-path=${path.resolve(__dirname, './__fixtures__/plugins/analytics_plugin_a')}`, `--plugin-path=${path.resolve(__dirname, './__fixtures__/plugins/analytics_ftr_helpers')}`, diff --git a/test/analytics/services/kibana_ebt.ts b/test/analytics/services/kibana_ebt.ts index fd64cbbbc010564..281794e899a3cf7 100644 --- a/test/analytics/services/kibana_ebt.ts +++ b/test/analytics/services/kibana_ebt.ts @@ -12,24 +12,27 @@ import '@kbn/analytics-ftr-helpers-plugin/public/types'; export function KibanaEBTServerProvider({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const setOptIn = async (optIn: boolean) => { + await supertest + .post(`/internal/analytics_ftr_helpers/opt_in`) + .set('kbn-xsrf', 'xxx') + .query({ consent: optIn }) + .expect(200); + }; + return { /** * Change the opt-in state of the Kibana EBT client. * @param optIn `true` to opt-in, `false` to opt-out. */ - setOptIn: async (optIn: boolean) => { - await supertest - .post(`/internal/analytics_ftr_helpers/opt_in`) - .set('kbn-xsrf', 'xxx') - .query({ consent: optIn }) - .expect(200); - }, + setOptIn, /** * Returns the last events of the specified types. * @param numberOfEvents - number of events to return * @param eventTypes (Optional) array of event types to return */ getLastEvents: async (takeNumberOfEvents: number, eventTypes: string[] = []) => { + await setOptIn(true); const resp = await supertest .get(`/internal/analytics_ftr_helpers/events`) .query({ takeNumberOfEvents, eventTypes: JSON.stringify(eventTypes) }) @@ -45,6 +48,10 @@ export function KibanaEBTUIProvider({ getService, getPageObjects }: FtrProviderC const { common } = getPageObjects(['common']); const browser = getService('browser'); + const setOptIn = async (optIn: boolean) => { + await browser.execute((isOptIn) => window.__analytics_ftr_helpers__.setOptIn(isOptIn), optIn); + }; + return { /** * Change the opt-in state of the Kibana EBT client. @@ -52,7 +59,7 @@ export function KibanaEBTUIProvider({ getService, getPageObjects }: FtrProviderC */ setOptIn: async (optIn: boolean) => { await common.navigateToApp('home'); - await browser.execute((isOptIn) => window.__analytics_ftr_helpers__.setOptIn(isOptIn), optIn); + await setOptIn(optIn); }, /** * Returns the last events of the specified types. @@ -60,6 +67,7 @@ export function KibanaEBTUIProvider({ getService, getPageObjects }: FtrProviderC * @param eventTypes (Optional) array of event types to return */ getLastEvents: async (numberOfEvents: number, eventTypes: string[] = []) => { + await setOptIn(true); const events = await browser.execute( ({ eventTypes: _eventTypes, numberOfEvents: _numberOfEvents }) => window.__analytics_ftr_helpers__.getLastEvents(_numberOfEvents, _eventTypes), diff --git a/test/analytics/tests/analytics_from_the_browser.ts b/test/analytics/tests/analytics_from_the_browser.ts index 7acabf2112c5d12..c05492fe3096174 100644 --- a/test/analytics/tests/analytics_from_the_browser.ts +++ b/test/analytics/tests/analytics_from_the_browser.ts @@ -72,6 +72,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(context).to.have.property('user_agent'); expect(context.user_agent).to.be.a('string'); + // Some context providers emit very early. We are OK with that. + const initialContext = actions[2].meta[0].context; + const reportEventContext = actions[2].meta[1].context; expect(reportEventContext).to.have.property('user_agent'); expect(reportEventContext.user_agent).to.be.a('string'); @@ -85,7 +88,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { { timestamp: actions[2].meta[0].timestamp, event_type: 'test-plugin-lifecycle', - context: {}, + context: initialContext, properties: { plugin: 'analyticsPluginA', step: 'setup' }, }, { @@ -103,7 +106,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { { timestamp: actions[2].meta[0].timestamp, event_type: 'test-plugin-lifecycle', - context: {}, + context: initialContext, properties: { plugin: 'analyticsPluginA', step: 'setup' }, }, { diff --git a/test/analytics/tests/analytics_from_the_server.ts b/test/analytics/tests/analytics_from_the_server.ts index e5e3573b20fcded..820f7e51adc9683 100644 --- a/test/analytics/tests/analytics_from_the_server.ts +++ b/test/analytics/tests/analytics_from_the_server.ts @@ -63,11 +63,19 @@ export default function ({ getService }: FtrProviderContext) { await ebtServerHelper.setOptIn(true); const actions = await getActions(3); + // Validating the remote PID because that's the only field that it's added by the FTR plugin. const context = actions[1].meta; expect(context).to.have.property('pid'); expect(context.pid).to.be.a('number'); + // Some context providers emit very early. We are OK with that. + const initialContext = actions[2].meta[0].context; + + const reportEventContext = actions[2].meta[1].context; + expect(context).to.have.property('pid'); + expect(context.pid).to.be.a('number'); + expect(actions).to.eql([ { action: 'optIn', meta: true }, { action: 'extendContext', meta: context }, @@ -77,13 +85,13 @@ export default function ({ getService }: FtrProviderContext) { { timestamp: actions[2].meta[0].timestamp, event_type: 'test-plugin-lifecycle', - context: {}, + context: initialContext, properties: { plugin: 'analyticsPluginA', step: 'setup' }, }, { timestamp: actions[2].meta[1].timestamp, event_type: 'test-plugin-lifecycle', - context, + context: reportEventContext, properties: { plugin: 'analyticsPluginA', step: 'start' }, }, ], @@ -96,13 +104,13 @@ export default function ({ getService }: FtrProviderContext) { { timestamp: actions[2].meta[0].timestamp, event_type: 'test-plugin-lifecycle', - context: {}, + context: initialContext, properties: { plugin: 'analyticsPluginA', step: 'setup' }, }, { timestamp: actions[2].meta[1].timestamp, event_type: 'test-plugin-lifecycle', - context, + context: reportEventContext, properties: { plugin: 'analyticsPluginA', step: 'start' }, }, ]); diff --git a/test/analytics/tests/instrumented_events/from_the_browser/click.ts b/test/analytics/tests/instrumented_events/from_the_browser/click.ts new file mode 100644 index 000000000000000..7b9816ba13e4e6a --- /dev/null +++ b/test/analytics/tests/instrumented_events/from_the_browser/click.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 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 '../../../services'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const ebtUIHelper = getService('kibana_ebt_ui'); + const { common } = getPageObjects(['common']); + + describe('General "click"', () => { + beforeEach(async () => { + await common.navigateToApp('home'); + // Just click on the top div and expect it's still there... we're just testing the click event generation + await common.clickAndValidate('kibanaChrome', 'kibanaChrome'); + }); + + it('should emit a "click" event', async () => { + const [event] = await ebtUIHelper.getLastEvents(1, ['click']); + expect(event.event_type).to.eql('click'); + expect(event.properties.target).to.be.an('array'); + const targets = event.properties.target as string[]; + expect(targets.includes('DIV')).to.be(true); + expect(targets.includes('id=kibana-body')).to.be(true); + expect(targets.includes('data-test-subj=kibanaChrome')).to.be(true); + }); + }); +} diff --git a/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts b/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.ts new file mode 100644 index 000000000000000..b6f691f419dabb5 --- /dev/null +++ b/test/analytics/tests/instrumented_events/from_the_browser/core_context_providers.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 expect from '@kbn/expect'; +import { Event } from '@kbn/core/public'; +import { FtrProviderContext } from '../../../services'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const deployment = getService('deployment'); + const ebtUIHelper = getService('kibana_ebt_ui'); + const { common } = getPageObjects(['common']); + + describe('Core Context Providers', () => { + let event: Event; + before(async () => { + await common.navigateToApp('home'); + [event] = await ebtUIHelper.getLastEvents(1, ['Loaded Kibana']); // Get the loaded Kibana event + }); + + it('should have the properties provided by the "cluster info" context provider', () => { + expect(event.context).to.have.property('cluster_uuid'); + expect(event.context.cluster_uuid).to.be.a('string'); + expect(event.context).to.have.property('cluster_name'); + expect(event.context.cluster_name).to.be.a('string'); + expect(event.context).to.have.property('cluster_version'); + expect(event.context.cluster_version).to.be.a('string'); + }); + + it('should have the properties provided by the "build info" context provider', () => { + expect(event.context).to.have.property('isDev'); + expect(event.context.isDev).to.be.a('boolean'); + expect(event.context).to.have.property('isDistributable'); + expect(event.context.isDistributable).to.be.a('boolean'); + expect(event.context).to.have.property('version'); + expect(event.context.version).to.be.a('string'); + expect(event.context).to.have.property('branch'); + expect(event.context.branch).to.be.a('string'); + expect(event.context).to.have.property('buildNum'); + expect(event.context.buildNum).to.be.a('number'); + expect(event.context).to.have.property('buildSha'); + expect(event.context.buildSha).to.be.a('string'); + }); + + it('should have the properties provided by the "session-id" context provider', () => { + expect(event.context).to.have.property('session_id'); + expect(event.context.session_id).to.be.a('string'); + }); + + it('should have the properties provided by the "browser info" context provider', () => { + expect(event.context).to.have.property('user_agent'); + expect(event.context.user_agent).to.be.a('string'); + expect(event.context).to.have.property('preferred_language'); + expect(event.context.preferred_language).to.be.a('string'); + expect(event.context).to.have.property('preferred_languages'); + expect(event.context.preferred_languages).to.be.an('array'); + (event.context.preferred_languages as unknown[]).forEach((lang) => + expect(lang).to.be.a('string') + ); + }); + + it('should have the properties provided by the "execution_context" context provider', () => { + expect(event.context).to.have.property('pageName'); + expect(event.context.pageName).to.be.a('string'); + expect(event.context).to.have.property('applicationId'); + expect(event.context.applicationId).to.be.a('string'); + expect(event.context).not.to.have.property('entityId'); // In the Home app it's not available. + expect(event.context).not.to.have.property('page'); // In the Home app it's not available. + }); + + it('should have the properties provided by the "license info" context provider', async () => { + await common.clickAndValidate('kibanaChrome', 'kibanaChrome'); + [event] = await ebtUIHelper.getLastEvents(1, ['click']); // Get a later event to ensure license has been obtained already. + expect(event.context).to.have.property('license_id'); + expect(event.context.license_id).to.be.a('string'); + expect(event.context).to.have.property('license_status'); + expect(event.context.license_status).to.be.a('string'); + expect(event.context).to.have.property('license_type'); + expect(event.context.license_type).to.be.a('string'); + }); + + it('should have the properties provided by the "Cloud Deployment ID" context provider', async () => { + if (await deployment.isCloud()) { + expect(event.context).to.have.property('cloudId'); + expect(event.context.cloudId).to.be.a('string'); + } else { + expect(event.context).not.to.have.property('cloudId'); + } + }); + }); +} diff --git a/test/analytics/tests/instrumented_events/from_the_browser/index.ts b/test/analytics/tests/instrumented_events/from_the_browser/index.ts index daf21180d2328da..2fe99373d521401 100644 --- a/test/analytics/tests/instrumented_events/from_the_browser/index.ts +++ b/test/analytics/tests/instrumented_events/from_the_browser/index.ts @@ -8,13 +8,11 @@ import { FtrProviderContext } from '../../../services'; -export default function ({ getService }: FtrProviderContext) { +export default function ({ loadTestFile }: FtrProviderContext) { describe('from the browser', () => { - beforeEach(async () => { - await getService('kibana_ebt_ui').setOptIn(true); - }); - // Add tests for UI-instrumented events here: - // loadTestFile(require.resolve('./some_event')); + loadTestFile(require.resolve('./click')); + loadTestFile(require.resolve('./loaded_kibana')); + loadTestFile(require.resolve('./core_context_providers')); }); } diff --git a/test/analytics/tests/instrumented_events/from_the_browser/loaded_kibana.ts b/test/analytics/tests/instrumented_events/from_the_browser/loaded_kibana.ts new file mode 100644 index 000000000000000..c7d3291cb03d408 --- /dev/null +++ b/test/analytics/tests/instrumented_events/from_the_browser/loaded_kibana.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 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 '../../../services'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const ebtUIHelper = getService('kibana_ebt_ui'); + const { common } = getPageObjects(['common']); + const browser = getService('browser'); + + describe('Loaded Kibana', () => { + beforeEach(async () => { + await common.navigateToApp('home'); + }); + + it('should emit the "Loaded Kibana" event', async () => { + const [event] = await ebtUIHelper.getLastEvents(1, ['Loaded Kibana']); + expect(event.event_type).to.eql('Loaded Kibana'); + expect(event.properties).to.have.property('kibana_version'); + expect(event.properties.kibana_version).to.be.a('string'); + + if (browser.isChromium) { + expect(event.properties).to.have.property('memory_js_heap_size_limit'); + expect(event.properties.memory_js_heap_size_limit).to.be.a('number'); + expect(event.properties).to.have.property('memory_js_heap_size_total'); + expect(event.properties.memory_js_heap_size_total).to.be.a('number'); + expect(event.properties).to.have.property('memory_js_heap_size_used'); + expect(event.properties.memory_js_heap_size_used).to.be.a('number'); + } + }); + }); +} diff --git a/test/analytics/tests/instrumented_events/from_the_server/core_context_providers.ts b/test/analytics/tests/instrumented_events/from_the_server/core_context_providers.ts new file mode 100644 index 000000000000000..743a32fcc58aceb --- /dev/null +++ b/test/analytics/tests/instrumented_events/from_the_server/core_context_providers.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { Event } from '@kbn/core/public'; +import { FtrProviderContext } from '../../../services'; + +export default function ({ getService }: FtrProviderContext) { + const deployment = getService('deployment'); + const ebtServerHelper = getService('kibana_ebt_server'); + + describe('Core Context Providers', () => { + let event: Event; + before(async () => { + // Wait for the 2nd "status_changed" event. At that point all the context providers should be set up. + [, event] = await ebtServerHelper.getLastEvents(2, ['core-overall_status_changed']); + }); + + it('should have the properties provided by the "kibana info" context provider', () => { + expect(event.context).to.have.property('kibana_uuid'); + expect(event.context.kibana_uuid).to.be.a('string'); + expect(event.context).to.have.property('pid'); + expect(event.context.pid).to.be.a('number'); + }); + + it('should have the properties provided by the "build info" context provider', () => { + expect(event.context).to.have.property('isDev'); + expect(event.context.isDev).to.be.a('boolean'); + expect(event.context).to.have.property('isDistributable'); + expect(event.context.isDistributable).to.be.a('boolean'); + expect(event.context).to.have.property('version'); + expect(event.context.version).to.be.a('string'); + expect(event.context).to.have.property('branch'); + expect(event.context.branch).to.be.a('string'); + expect(event.context).to.have.property('buildNum'); + expect(event.context.buildNum).to.be.a('number'); + expect(event.context).to.have.property('buildSha'); + expect(event.context.buildSha).to.be.a('string'); + }); + + it('should have the properties provided by the "cluster info" context provider', () => { + expect(event.context).to.have.property('cluster_uuid'); + expect(event.context.cluster_uuid).to.be.a('string'); + expect(event.context).to.have.property('cluster_name'); + expect(event.context.cluster_name).to.be.a('string'); + expect(event.context).to.have.property('cluster_version'); + expect(event.context.cluster_version).to.be.a('string'); + }); + + it('should have the properties provided by the "status info" context provider', () => { + expect(event.context).to.have.property('overall_status_level'); + expect(event.context.overall_status_level).to.be.a('string'); + expect(event.context).to.have.property('overall_status_summary'); + expect(event.context.overall_status_summary).to.be.a('string'); + }); + + it('should have the properties provided by the "license info" context provider', () => { + expect(event.context).to.have.property('license_id'); + expect(event.context.license_id).to.be.a('string'); + expect(event.context).to.have.property('license_status'); + expect(event.context.license_status).to.be.a('string'); + expect(event.context).to.have.property('license_type'); + expect(event.context.license_type).to.be.a('string'); + }); + + it('should have the properties provided by the "Cloud Deployment ID" context provider', async () => { + if (await deployment.isCloud()) { + expect(event.context).to.have.property('cloudId'); + expect(event.context.cloudId).to.be.a('string'); + } else { + expect(event.context).not.to.have.property('cloudId'); + } + }); + }); +} diff --git a/test/analytics/tests/instrumented_events/from_the_server/core_overall_status_changed.ts b/test/analytics/tests/instrumented_events/from_the_server/core_overall_status_changed.ts new file mode 100644 index 000000000000000..fa94e2b69fc3f1e --- /dev/null +++ b/test/analytics/tests/instrumented_events/from_the_server/core_overall_status_changed.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { Event } from '@kbn/analytics-client'; +import { FtrProviderContext } from '../../../services'; + +export default function ({ getService }: FtrProviderContext) { + const ebtServerHelper = getService('kibana_ebt_server'); + + describe('core-overall_status_changed', () => { + let initialEvent: Event; + let secondEvent: Event; + + before(async () => { + [initialEvent, secondEvent] = await ebtServerHelper.getLastEvents(2, [ + 'core-overall_status_changed', + ]); + }); + + it('should emit the initial "degraded" event with the context set to `initializing`', () => { + expect(initialEvent.event_type).to.eql('core-overall_status_changed'); + expect(initialEvent.context).to.have.property('overall_status_level', 'initializing'); + expect(initialEvent.context).to.have.property( + 'overall_status_summary', + 'Kibana is starting up' + ); + expect(initialEvent.properties).to.have.property('overall_status_level', 'degraded'); + expect(initialEvent.properties.overall_status_summary).to.be.a('string'); + }); + + it('should emit the 2nd event as `available` with the context set to the previous values', () => { + expect(secondEvent.event_type).to.eql('core-overall_status_changed'); + expect(secondEvent.context).to.have.property( + 'overall_status_level', + initialEvent.properties.overall_status_level + ); + expect(secondEvent.context).to.have.property( + 'overall_status_summary', + initialEvent.properties.overall_status_summary + ); + expect(secondEvent.properties.overall_status_level).to.be.a('string'); // Ideally we would test it as `available`, but we can't do that as it may result flaky for many side effects in the CI. + expect(secondEvent.properties.overall_status_summary).to.be.a('string'); + }); + }); +} diff --git a/test/analytics/tests/instrumented_events/from_the_server/index.ts b/test/analytics/tests/instrumented_events/from_the_server/index.ts index 8961b9e92994c68..d8150b0519fdeb0 100644 --- a/test/analytics/tests/instrumented_events/from_the_server/index.ts +++ b/test/analytics/tests/instrumented_events/from_the_server/index.ts @@ -8,13 +8,11 @@ import { FtrProviderContext } from '../../../services'; -export default function ({ getService }: FtrProviderContext) { +export default function ({ loadTestFile }: FtrProviderContext) { describe('from the server', () => { - beforeEach(async () => { - await getService('kibana_ebt_server').setOptIn(true); - }); - - // Add tests for UI-instrumented events here: - // loadTestFile(require.resolve('./some_event')); + // Add tests for Server-instrumented events here: + loadTestFile(require.resolve('./core_context_providers')); + loadTestFile(require.resolve('./kibana_started')); + loadTestFile(require.resolve('./core_overall_status_changed')); }); } diff --git a/test/analytics/tests/instrumented_events/from_the_server/kibana_started.ts b/test/analytics/tests/instrumented_events/from_the_server/kibana_started.ts new file mode 100644 index 000000000000000..86917b937cbabd7 --- /dev/null +++ b/test/analytics/tests/instrumented_events/from_the_server/kibana_started.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 '../../../services'; + +export default function ({ getService }: FtrProviderContext) { + const ebtServerHelper = getService('kibana_ebt_server'); + + describe('kibana_started', () => { + it('should emit the "kibana_started" event', async () => { + const [event] = await ebtServerHelper.getLastEvents(1, ['kibana_started']); + expect(event.event_type).to.eql('kibana_started'); + expect(event.properties.uptime_per_step.constructor.start).to.be.a('number'); + expect(event.properties.uptime_per_step.constructor.end).to.be.a('number'); + expect(event.properties.uptime_per_step.preboot.start).to.be.a('number'); + expect(event.properties.uptime_per_step.preboot.end).to.be.a('number'); + expect(event.properties.uptime_per_step.setup.start).to.be.a('number'); + expect(event.properties.uptime_per_step.setup.end).to.be.a('number'); + expect(event.properties.uptime_per_step.start.start).to.be.a('number'); + expect(event.properties.uptime_per_step.start.end).to.be.a('number'); + }); + }); +} diff --git a/test/api_integration/apis/custom_integration/integrations.ts b/test/api_integration/apis/custom_integration/integrations.ts index c4fda918328f82d..c1b6518f6684a9a 100644 --- a/test/api_integration/apis/custom_integration/integrations.ts +++ b/test/api_integration/apis/custom_integration/integrations.ts @@ -22,7 +22,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body).to.be.an('array'); - expect(resp.body.length).to.be(42); + expect(resp.body.length).to.be(43); // Test for sample data card expect(resp.body.findIndex((c: { id: string }) => c.id === 'sample_data_all')).to.be.above( diff --git a/test/common/fixtures/plugins/newsfeed/server/plugin.ts b/test/common/fixtures/plugins/newsfeed/server/plugin.ts index 85dadcfa8d7d260..5eb27325e535cc0 100644 --- a/test/common/fixtures/plugins/newsfeed/server/plugin.ts +++ b/test/common/fixtures/plugins/newsfeed/server/plugin.ts @@ -59,7 +59,7 @@ export class NewsFeedSimulatorPlugin implements Plugin { title: { en: 'Staging too!' }, description: { en: 'Hello world' }, link_text: { en: 'Generic feed-viewer could go here' }, - link_url: { en: 'https://feeds-staging.elastic.co' }, + link_url: { en: 'https://feeds.elastic.co' }, languages: null, badge: null, image_url: null, @@ -71,7 +71,7 @@ export class NewsFeedSimulatorPlugin implements Plugin { title: { en: 'This item is expired!' }, description: { en: 'This should not show up.' }, link_text: { en: 'Generic feed-viewer could go here' }, - link_url: { en: 'https://feeds-staging.elastic.co' }, + link_url: { en: 'https://feeds.elastic.co' }, languages: null, badge: null, image_url: null, diff --git a/test/common/services/es_archiver.ts b/test/common/services/es_archiver.ts index f5b7ee8cc588551..8a9106e9281527c 100644 --- a/test/common/services/es_archiver.ts +++ b/test/common/services/es_archiver.ts @@ -13,10 +13,11 @@ import * as KibanaServer from './kibana_server'; export function EsArchiverProvider({ getService }: FtrProviderContext): EsArchiver { const config = getService('config'); const client = getService('es'); - + const lifecycle = getService('lifecycle'); const log = getService('log'); const kibanaServer = getService('kibanaServer'); const retry = getService('retry'); + const esArchives = config.get('testData.esArchives'); const esArchiver = new EsArchiver({ client, @@ -31,5 +32,19 @@ export function EsArchiverProvider({ getService }: FtrProviderContext): EsArchiv defaults: config.get('uiSettings.defaults'), }); + if (esArchives) { + lifecycle.beforeTests.add(async () => { + for (const archive of esArchives) { + await esArchiver.load(archive); + } + }); + + lifecycle.cleanup.add(async () => { + for (const archive of esArchives) { + await esArchiver.unload(archive); + } + }); + } + return esArchiver; } diff --git a/test/common/services/kibana_server/kibana_server.ts b/test/common/services/kibana_server/kibana_server.ts index f20fa4cafa55e2b..85bf463648205e9 100644 --- a/test/common/services/kibana_server/kibana_server.ts +++ b/test/common/services/kibana_server/kibana_server.ts @@ -17,6 +17,7 @@ export function KibanaServerProvider({ getService }: FtrProviderContext): KbnCli const lifecycle = getService('lifecycle'); const url = Url.format(config.get('servers.kibana')); const defaults = config.get('uiSettings.defaults'); + const kbnArchives = config.get('testData.kbnArchives'); const kbn = new KbnClient({ log, url, @@ -30,5 +31,18 @@ export function KibanaServerProvider({ getService }: FtrProviderContext): KbnCli }); } + if (kbnArchives) { + lifecycle.beforeTests.add(async () => { + for (const archive of kbnArchives) { + await kbn.importExport.load(archive); + } + }); + lifecycle.cleanup.add(async () => { + for (const archive of kbnArchives) { + await kbn.importExport.unload(archive); + } + }); + } + return kbn; } diff --git a/test/examples/embeddables/dashboard.ts b/test/examples/embeddables/dashboard.ts index 073b9d9ee3b86b2..915867ccd57aae4 100644 --- a/test/examples/embeddables/dashboard.ts +++ b/test/examples/embeddables/dashboard.ts @@ -116,9 +116,7 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide }); it('pie charts', async () => { - if (await PageObjects.visChart.isNewChartsLibraryEnabled()) { - await elasticChart.setNewChartUiDebugFlag(); - } + await elasticChart.setNewChartUiDebugFlag(); await pieChart.expectPieSliceCount(5); }); diff --git a/test/functional/apps/dashboard/group1/dashboard_query_bar.ts b/test/functional/apps/dashboard/group1/dashboard_query_bar.ts index 290cc62dca58f27..6890ba5bed24f59 100644 --- a/test/functional/apps/dashboard/group1/dashboard_query_bar.ts +++ b/test/functional/apps/dashboard/group1/dashboard_query_bar.ts @@ -38,12 +38,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('causes panels to reload when refresh is clicked', async () => { await esArchiver.unload('test/functional/fixtures/es_archiver/dashboard/current/data'); - await queryBar.clickQuerySubmitButton(); await retry.tryForTime(5000, async () => { const headers = await PageObjects.discover.getColumnHeaders(); expect(headers.length).to.be(0); - await pieChart.expectPieSliceCount(0); + await pieChart.expectEmptyPieChart(); }); }); }); diff --git a/test/functional/apps/dashboard/group1/embed_mode.ts b/test/functional/apps/dashboard/group1/embed_mode.ts index 25f48236ab7d589..482c976d98689a0 100644 --- a/test/functional/apps/dashboard/group1/embed_mode.ts +++ b/test/functional/apps/dashboard/group1/embed_mode.ts @@ -59,7 +59,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.missingOrFail('top-nav'); await testSubjects.missingOrFail('queryInput'); await testSubjects.missingOrFail('superDatePickerToggleQuickMenuButton'); - await testSubjects.existOrFail('showFilterActions'); + await testSubjects.existOrFail('showQueryBarMenu'); const currentUrl = await browser.getCurrentUrl(); const newUrl = [currentUrl].concat(urlParamExtensions).join('&'); @@ -70,7 +70,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await testSubjects.existOrFail('top-nav'); await testSubjects.existOrFail('queryInput'); await testSubjects.existOrFail('superDatePickerToggleQuickMenuButton'); - await testSubjects.missingOrFail('showFilterActions'); }); after(async function () { diff --git a/test/functional/apps/dashboard/group1/embeddable_rendering.ts b/test/functional/apps/dashboard/group1/embeddable_rendering.ts index 5274a2c12e878bd..f57b1f1fda83ada 100644 --- a/test/functional/apps/dashboard/group1/embeddable_rendering.ts +++ b/test/functional/apps/dashboard/group1/embeddable_rendering.ts @@ -39,7 +39,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { let visNames: string[] = []; const expectAllDataRenders = async () => { - await pieChart.expectPieSliceCount(16); + await pieChart.expectSliceCountForAllPies(16); await dashboardExpect.metricValuesExist(['7,544']); await dashboardExpect.seriesElementCount(14); const tsvbGuageExists = await find.existsByCssSelector('.tvbVisHalfGauge'); @@ -73,7 +73,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }; const expectNoDataRenders = async () => { - await pieChart.expectPieSliceCount(0); + await pieChart.expectEmptyPieChart(); await dashboardExpect.seriesElementCount(0); await dashboardExpect.dataTableNoResult(); await dashboardExpect.savedSearchNoResult(); @@ -112,6 +112,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.preserveCrossAppState(); await PageObjects.dashboard.clickNewDashboard(); + await elasticChart.setNewChartUiDebugFlag(true); const fromTime = 'Jan 1, 2018 @ 00:00:00.000'; const toTime = 'Apr 13, 2018 @ 00:00:00.000'; @@ -160,6 +161,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('initial render test', async () => { await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.dashboard.waitForRenderComplete(); + await elasticChart.setNewChartUiDebugFlag(); await expectAllDataRenders(); }); @@ -178,9 +180,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const alert = await browser.getAlert(); await alert?.accept(); - await elasticChart.setNewChartUiDebugFlag(true); - await PageObjects.header.waitUntilLoadingHasFinished(); + await elasticChart.setNewChartUiDebugFlag(); await PageObjects.dashboard.waitForRenderComplete(); await expectAllDataRenders(); }); diff --git a/test/functional/apps/dashboard/group1/legacy_urls.ts b/test/functional/apps/dashboard/group1/legacy_urls.ts index e11da2d82fe4784..54834bf8969b707 100644 --- a/test/functional/apps/dashboard/group1/legacy_urls.ts +++ b/test/functional/apps/dashboard/group1/legacy_urls.ts @@ -25,6 +25,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const listingTable = getService('listingTable'); const kibanaServer = getService('kibanaServer'); const security = getService('security'); + const elasticChart = getService('elasticChart'); let kibanaLegacyBaseUrl: string; let kibanaVisualizeBaseUrl: string; @@ -63,6 +64,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const url = `${kibanaLegacyBaseUrl}#/dashboard/${testDashboardId}`; await browser.get(url, true); await PageObjects.header.waitUntilLoadingHasFinished(); + await elasticChart.setNewChartUiDebugFlag(true); await PageObjects.timePicker.setDefaultDataRange(); await PageObjects.dashboard.waitForRenderComplete(); @@ -72,6 +74,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('redirects from legacy hash in wrong app', async () => { const url = `${kibanaVisualizeBaseUrl}#/dashboard/${testDashboardId}`; await browser.get(url, true); + await elasticChart.setNewChartUiDebugFlag(true); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.timePicker.setDefaultDataRange(); @@ -111,6 +114,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardAddPanel.addVisualization('legacy url markdown'); (await find.byLinkText('abc')).click(); await PageObjects.header.waitUntilLoadingHasFinished(); + await elasticChart.setNewChartUiDebugFlag(true); await PageObjects.timePicker.setDefaultDataRange(); await PageObjects.dashboard.waitForRenderComplete(); diff --git a/test/functional/apps/dashboard/group2/dashboard_filter_bar.ts b/test/functional/apps/dashboard/group2/dashboard_filter_bar.ts index 966b453409433fc..bb49225f32d81fc 100644 --- a/test/functional/apps/dashboard/group2/dashboard_filter_bar.ts +++ b/test/functional/apps/dashboard/group2/dashboard_filter_bar.ts @@ -17,8 +17,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const filterBar = getService('filterBar'); const pieChart = getService('pieChart'); + const elasticChart = getService('elasticChart'); const kibanaServer = getService('kibanaServer'); const browser = getService('browser'); + const queryBar = getService('queryBar'); const security = getService('security'); const PageObjects = getPageObjects([ 'common', @@ -109,6 +111,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.gotoDashboardLandingPage(); await PageObjects.dashboard.clickNewDashboard(); await PageObjects.timePicker.setDefaultDataRange(); + await elasticChart.setNewChartUiDebugFlag(true); }); it('are not selected by default', async function () { @@ -119,7 +122,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('are added when a pie chart slice is clicked', async function () { await dashboardAddPanel.addVisualization('Rendering Test: pie'); await PageObjects.dashboard.waitForRenderComplete(); - await pieChart.filterOnPieSlice('4,886'); + await pieChart.filterOnPieSlice('4886'); const filterCount = await filterBar.getFilterCount(); expect(filterCount).to.equal(1); @@ -129,6 +132,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('are preserved after saving a dashboard', async () => { await PageObjects.dashboard.saveDashboard('with filters'); await PageObjects.header.waitUntilLoadingHasFinished(); + await elasticChart.setNewChartUiDebugFlag(true); const filterCount = await filterBar.getFilterCount(); expect(filterCount).to.equal(1); @@ -140,6 +144,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.gotoDashboardLandingPage(); await PageObjects.dashboard.loadSavedDashboard('with filters'); await PageObjects.header.waitUntilLoadingHasFinished(); + await elasticChart.setNewChartUiDebugFlag(true); + await queryBar.submitQuery(); const filterCount = await filterBar.getFilterCount(); expect(filterCount).to.equal(1); @@ -152,6 +158,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await browser.goForward(); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.dashboard.waitForRenderComplete(); + await elasticChart.setNewChartUiDebugFlag(true); + await queryBar.submitQuery(); await pieChart.expectPieSliceCount(1); }); diff --git a/test/functional/apps/dashboard/group2/dashboard_filtering.ts b/test/functional/apps/dashboard/group2/dashboard_filtering.ts index 09acbd5965020ef..9beef9ece92e6f1 100644 --- a/test/functional/apps/dashboard/group2/dashboard_filtering.ts +++ b/test/functional/apps/dashboard/group2/dashboard_filtering.ts @@ -18,6 +18,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const dashboardExpect = getService('dashboardExpect'); const pieChart = getService('pieChart'); const queryBar = getService('queryBar'); + const elasticChart = getService('elasticChart'); const dashboardAddPanel = getService('dashboardAddPanel'); const renderable = getService('renderable'); const testSubjects = getService('testSubjects'); @@ -47,6 +48,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await queryBar.clickQuerySubmitButton(); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.dashboard.waitForRenderComplete(); + await elasticChart.setNewChartUiDebugFlag(true); }; before(async () => { @@ -85,7 +87,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('filters on pie charts', async () => { - await pieChart.expectPieSliceCount(0); + await pieChart.expectEmptyPieChart(); }); it('area, bar and heatmap charts filtered', async () => { @@ -150,7 +152,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('filters on pie charts', async () => { - await pieChart.expectPieSliceCount(0); + await pieChart.expectEmptyPieChart(); }); it('area, bar and heatmap charts filtered', async () => { @@ -253,6 +255,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('nested filtering', () => { before(async () => { await PageObjects.dashboard.gotoDashboardLandingPage(); + await elasticChart.setNewChartUiDebugFlag(true); }); it('visualization saved with a query filters data', async () => { @@ -323,10 +326,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardAddPanel.addVisualization( 'Filter Test: animals: linked to search with filter' ); - await pieChart.expectPieSliceCount(7); + await elasticChart.setNewChartUiDebugFlag(true); + await pieChart.expectSliceCountForAllPies(7); }); it('Pie chart linked to saved search filters shows no data with conflicting dashboard query', async () => { + await elasticChart.setNewChartUiDebugFlag(true); await queryBar.setQuery('weightLbs<40'); await queryBar.submitQuery(); await PageObjects.dashboard.waitForRenderComplete(); diff --git a/test/functional/apps/dashboard/group2/dashboard_saved_query.ts b/test/functional/apps/dashboard/group2/dashboard_saved_query.ts index ac9613f4bf400b7..1dad54234e8a3c5 100644 --- a/test/functional/apps/dashboard/group2/dashboard_saved_query.ts +++ b/test/functional/apps/dashboard/group2/dashboard_saved_query.ts @@ -40,22 +40,30 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.dashboard.clickNewDashboard(); }); - it('should show the saved query management component when there are no saved queries', async () => { - await savedQueryManagementComponent.openSavedQueryManagementComponent(); - const descriptionText = await testSubjects.getVisibleText('saved-query-management-popover'); - expect(descriptionText).to.eql( - 'Saved Queries\nThere are no saved queries. Save query text and filters that you want to use again.\nSave current query' - ); + it('should show the saved query management load button as disabled when there are no saved queries', async () => { + await testSubjects.click('showQueryBarMenu'); + const loadFilterSetBtn = await testSubjects.find('saved-query-management-load-button'); + const isDisabled = await loadFilterSetBtn.getAttribute('disabled'); + expect(isDisabled).to.equal('true'); }); it('should allow a query to be saved via the saved objects management component', async () => { await queryBar.setQuery('response:200'); + await queryBar.clickQuerySubmitButton(); + await testSubjects.click('showQueryBarMenu'); await savedQueryManagementComponent.saveNewQuery( 'OkResponse', '200 responses for .jpg over 24 hours', true, true ); + const contextMenuPanelTitleButton = await testSubjects.exists( + 'contextMenuPanelTitleButton' + ); + if (contextMenuPanelTitleButton) { + await testSubjects.click('contextMenuPanelTitleButton'); + } + await savedQueryManagementComponent.savedQueryExistOrFail('OkResponse'); await savedQueryManagementComponent.savedQueryTextExist('response:200'); }); @@ -81,6 +89,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await queryBar.setQuery('response:404'); await savedQueryManagementComponent.updateCurrentlyLoadedQuery('OkResponse', false, false); await savedQueryManagementComponent.savedQueryExistOrFail('OkResponse'); + const contextMenuPanelTitleButton = await testSubjects.exists( + 'contextMenuPanelTitleButton' + ); + if (contextMenuPanelTitleButton) { + await testSubjects.click('contextMenuPanelTitleButton'); + } await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); expect(await queryBar.getQueryString()).to.eql(''); await savedQueryManagementComponent.loadSavedQuery('OkResponse'); @@ -88,9 +102,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('allows saving the currently loaded query as a new query', async () => { + await queryBar.setQuery('response:400'); await savedQueryManagementComponent.saveCurrentlyLoadedAsNewQuery( 'OkResponseCopy', - '200 responses', + '400 responses', false, false ); diff --git a/test/functional/apps/dashboard/group2/dashboard_snapshots.ts b/test/functional/apps/dashboard/group2/dashboard_snapshots.ts index dc1a74ea74b7d86..56dcfe2388bc257 100644 --- a/test/functional/apps/dashboard/group2/dashboard_snapshots.ts +++ b/test/functional/apps/dashboard/group2/dashboard_snapshots.ts @@ -84,7 +84,7 @@ export default function ({ ); await PageObjects.dashboard.clickExitFullScreenLogoButton(); - expect(percentDifference).to.be.lessThan(0.022); + expect(percentDifference).to.be.lessThan(0.029); }); }); } diff --git a/test/functional/apps/dashboard/group3/bwc_shared_urls.ts b/test/functional/apps/dashboard/group3/bwc_shared_urls.ts index 01b1c8379089ebc..35d13b715c14c5c 100644 --- a/test/functional/apps/dashboard/group3/bwc_shared_urls.ts +++ b/test/functional/apps/dashboard/group3/bwc_shared_urls.ts @@ -14,6 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['dashboard', 'header']); const dashboardExpect = getService('dashboardExpect'); const pieChart = getService('pieChart'); + const elasticChart = getService('elasticChart'); const browser = getService('browser'); const log = getService('log'); const queryBar = getService('queryBar'); @@ -41,6 +42,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { `legendOpen:!t))),` + `viewMode:edit)`; + const enableNewChartLibraryDebug = async () => { + await elasticChart.setNewChartUiDebugFlag(); + await queryBar.submitQuery(); + }; + describe('bwc shared urls', function describeIndexTests() { before(async function () { await PageObjects.dashboard.initTests(); @@ -75,11 +81,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { log.debug(`Navigating to ${url}`); await browser.get(url, true); await PageObjects.header.waitUntilLoadingHasFinished(); + await elasticChart.setNewChartUiDebugFlag(true); const query = await queryBar.getQueryString(); expect(query).to.equal('memory:>220000'); - await pieChart.expectPieSliceCount(0); + await pieChart.expectEmptyPieChart(); await dashboardExpect.panelCount(2); await PageObjects.dashboard.waitForRenderComplete(); }); @@ -92,8 +99,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const url = `${kibanaLegacyBaseUrl}#/dashboard?${urlQuery}`; log.debug(`Navigating to ${url}`); await browser.get(url, true); + enableNewChartLibraryDebug(); await PageObjects.header.waitUntilLoadingHasFinished(); - const query = await queryBar.getQueryString(); expect(query).to.equal('memory:>220000'); @@ -113,6 +120,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { log.debug(`Navigating to ${url}`); await browser.get(url, true); await PageObjects.header.waitUntilLoadingHasFinished(); + enableNewChartLibraryDebug(); const query = await queryBar.getQueryString(); expect(query).to.equal('memory:>220000'); @@ -146,8 +154,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { log.debug(`Navigating to ${url}`); await browser.get(url, true); + await elasticChart.setNewChartUiDebugFlag(true); await PageObjects.header.waitUntilLoadingHasFinished(); - await dashboardExpect.selectedLegendColorCount('#000000', 5); }); @@ -160,6 +168,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const url = `${kibanaLegacyBaseUrl}#/dashboard?${urlQuery}`; log.debug(`Navigating to ${url}`); await browser.get(url); + await elasticChart.setNewChartUiDebugFlag(true); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.dashboard.waitForRenderComplete(); await dashboardExpect.selectedLegendColorCount('#F9D9F9', 5); @@ -169,6 +178,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const newId = await PageObjects.dashboard.getDashboardIdFromCurrentUrl(); expect(newId).to.be.equal(oldId); await PageObjects.dashboard.waitForRenderComplete(); + await elasticChart.setNewChartUiDebugFlag(true); + await queryBar.submitQuery(); await dashboardExpect.selectedLegendColorCount('#000000', 5); }); }); diff --git a/test/functional/apps/dashboard/group3/dashboard_state.ts b/test/functional/apps/dashboard/group3/dashboard_state.ts index 48fb9233682ad01..79179b7c9d08bab 100644 --- a/test/functional/apps/dashboard/group3/dashboard_state.ts +++ b/test/functional/apps/dashboard/group3/dashboard_state.ts @@ -42,19 +42,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('dashboard state', function describeIndexTests() { // Used to track flag before and after reset - let isNewChartsLibraryEnabled = false; + let isNewChartsLibraryEnabled = true; before(async function () { isNewChartsLibraryEnabled = await PageObjects.visChart.isNewChartsLibraryEnabled(); await PageObjects.dashboard.initTests(); await PageObjects.dashboard.preserveCrossAppState(); + await browser.setLocalStorageItem('data.newDataViewMenu', 'true'); - if (isNewChartsLibraryEnabled) { + if (!isNewChartsLibraryEnabled) { await kibanaServer.uiSettings.update({ - 'visualization:visualize:legacyPieChartsLibrary': false, + 'visualization:visualize:legacyPieChartsLibrary': true, }); - await browser.refresh(); } + await browser.refresh(); }); after(async function () { diff --git a/test/functional/apps/dashboard/group3/dashboard_time_picker.ts b/test/functional/apps/dashboard/group3/dashboard_time_picker.ts index 37f6e4f2ef5df68..a0554eaea184254 100644 --- a/test/functional/apps/dashboard/group3/dashboard_time_picker.ts +++ b/test/functional/apps/dashboard/group3/dashboard_time_picker.ts @@ -14,6 +14,7 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const dashboardExpect = getService('dashboardExpect'); const pieChart = getService('pieChart'); + const elasticChart = getService('elasticChart'); const dashboardVisualizations = getService('dashboardVisualizations'); const PageObjects = getPageObjects([ 'dashboard', @@ -31,6 +32,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { before(async function () { await PageObjects.dashboard.initTests(); await PageObjects.dashboard.preserveCrossAppState(); + await elasticChart.setNewChartUiDebugFlag(true); }); after(async () => { @@ -43,7 +45,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('Visualization updated when time picker changes', async () => { await PageObjects.dashboard.clickNewDashboard(); await PageObjects.dashboard.addVisualizations([PIE_CHART_VIS_NAME]); - await pieChart.expectPieSliceCount(0); + await pieChart.expectEmptyPieChart(); await PageObjects.timePicker.setHistoricalDataRange(); await pieChart.expectPieSliceCount(10); @@ -124,6 +126,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { '2015-09-19 06:31:44.000', '2015-09-23 18:31:44.000' ); + await elasticChart.setNewChartUiDebugFlag(true); await pieChart.expectPieSliceCount(10); }); }); diff --git a/test/functional/apps/dashboard/group5/index.ts b/test/functional/apps/dashboard/group5/index.ts index 14f4a6366477dc1..e05c980dda3518f 100644 --- a/test/functional/apps/dashboard/group5/index.ts +++ b/test/functional/apps/dashboard/group5/index.ts @@ -25,11 +25,11 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { describe('dashboard app - group 5', function () { // TODO: Remove when vislib is removed // https://github.com/elastic/kibana/issues/56143 - describe('new charts library', function () { + describe('old charts library', function () { before(async () => { await loadLogstash(); await kibanaServer.uiSettings.update({ - 'visualization:visualize:legacyPieChartsLibrary': false, + 'visualization:visualize:legacyPieChartsLibrary': true, }); await browser.refresh(); }); @@ -37,7 +37,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { after(async () => { await unloadLogstash(); await kibanaServer.uiSettings.update({ - 'visualization:visualize:legacyPieChartsLibrary': true, + 'visualization:visualize:legacyPieChartsLibrary': false, }); await browser.refresh(); }); diff --git a/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts b/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts index 13ef3a248a5836e..ba25fcfce98e203 100644 --- a/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts +++ b/test/functional/apps/dashboard_elements/controls/control_group_chaining.ts @@ -7,6 +7,7 @@ */ import expect from '@kbn/expect'; +import { OPTIONS_LIST_CONTROL } from '@kbn/controls-plugin/common'; import { FtrProviderContext } from '../../../ftr_provider_context'; @@ -37,19 +38,22 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await timePicker.setDefaultDataRange(); // populate an initial set of controls and get their ids. - await dashboardControls.createOptionsListControl({ + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, dataViewTitle: 'animals-*', fieldName: 'animal.keyword', title: 'Animal', }); - await dashboardControls.createOptionsListControl({ + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, dataViewTitle: 'animals-*', fieldName: 'name.keyword', title: 'Animal Name', }); - await dashboardControls.createOptionsListControl({ + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, dataViewTitle: 'animals-*', fieldName: 'sound.keyword', title: 'Animal Sound', diff --git a/test/functional/apps/dashboard_elements/controls/control_group_settings.ts b/test/functional/apps/dashboard_elements/controls/control_group_settings.ts index 3ca09bba99cea59..de3a70b6a49d2d1 100644 --- a/test/functional/apps/dashboard_elements/controls/control_group_settings.ts +++ b/test/functional/apps/dashboard_elements/controls/control_group_settings.ts @@ -7,6 +7,7 @@ */ import expect from '@kbn/expect'; +import { OPTIONS_LIST_CONTROL } from '@kbn/controls-plugin/common'; import { FtrProviderContext } from '../../../ftr_provider_context'; @@ -29,7 +30,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('adjust layout of controls', async () => { await dashboard.switchToEditMode(); - await dashboardControls.createOptionsListControl({ + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, dataViewTitle: 'animals-*', fieldName: 'sound.keyword', }); @@ -41,7 +43,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('apply new default size', async () => { it('to new controls only', async () => { await dashboardControls.updateControlsSize('medium'); - await dashboardControls.createOptionsListControl({ + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, dataViewTitle: 'animals-*', fieldName: 'name.keyword', }); @@ -54,7 +57,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('to all existing controls', async () => { - await dashboardControls.createOptionsListControl({ + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, dataViewTitle: 'animals-*', fieldName: 'animal.keyword', width: 'large', @@ -82,7 +86,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('when at least one control', async () => { - await dashboardControls.createOptionsListControl({ + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, dataViewTitle: 'animals-*', fieldName: 'sound.keyword', }); diff --git a/test/functional/apps/dashboard_elements/controls/controls_callout.ts b/test/functional/apps/dashboard_elements/controls/controls_callout.ts index fc6316940c8a46c..73bf8fb50c2412f 100644 --- a/test/functional/apps/dashboard_elements/controls/controls_callout.ts +++ b/test/functional/apps/dashboard_elements/controls/controls_callout.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import { OPTIONS_LIST_CONTROL } from '@kbn/controls-plugin/common'; + import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { @@ -47,7 +49,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('adding control hides the empty control callout', async () => { - await dashboardControls.createOptionsListControl({ + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, dataViewTitle: 'animals-*', fieldName: 'sound.keyword', }); diff --git a/test/functional/apps/dashboard_elements/controls/index.ts b/test/functional/apps/dashboard_elements/controls/index.ts index f5ec41d593995d0..c867f00b32e828d 100644 --- a/test/functional/apps/dashboard_elements/controls/index.ts +++ b/test/functional/apps/dashboard_elements/controls/index.ts @@ -51,5 +51,6 @@ export default function ({ loadTestFile, getService, getPageObjects }: FtrProvid loadTestFile(require.resolve('./options_list')); loadTestFile(require.resolve('./range_slider')); loadTestFile(require.resolve('./control_group_chaining')); + loadTestFile(require.resolve('./replace_controls')); }); } diff --git a/test/functional/apps/dashboard_elements/controls/options_list.ts b/test/functional/apps/dashboard_elements/controls/options_list.ts index a4da1c217f92b1c..17a028a39464efa 100644 --- a/test/functional/apps/dashboard_elements/controls/options_list.ts +++ b/test/functional/apps/dashboard_elements/controls/options_list.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { OPTIONS_LIST_CONTROL } from '@kbn/controls-plugin/common'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; @@ -14,6 +15,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const queryBar = getService('queryBar'); const pieChart = getService('pieChart'); + const elasticChart = getService('elasticChart'); const filterBar = getService('filterBar'); const testSubjects = getService('testSubjects'); const dashboardAddPanel = getService('dashboardAddPanel'); @@ -32,6 +34,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboard.gotoDashboardLandingPage(); await dashboard.clickNewDashboard(); await timePicker.setDefaultDataRange(); + await elasticChart.setNewChartUiDebugFlag(); }); describe('Options List Control Editor selects relevant data views', async () => { @@ -55,7 +58,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('selects the last used data view by default', async () => { - await dashboardControls.createOptionsListControl({ + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, dataViewTitle: 'animals-*', fieldName: 'sound.keyword', }); @@ -68,7 +72,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('Options List Control creation and editing experience', async () => { it('can add a new options list control from a blank state', async () => { - await dashboardControls.createOptionsListControl({ + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, dataViewTitle: 'logstash-*', fieldName: 'machine.os.raw', }); @@ -76,7 +81,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('can add a second options list control with a non-default data view', async () => { - await dashboardControls.createOptionsListControl({ + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, dataViewTitle: 'animals-*', fieldName: 'sound.keyword', }); @@ -184,7 +190,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { before(async () => { await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); - await dashboardControls.createOptionsListControl({ + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, dataViewTitle: 'animals-*', fieldName: 'sound.keyword', title: 'Animal Sounds', @@ -262,49 +269,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - describe('Does not apply query settings to controls', async () => { - before(async () => { - await dashboardControls.updateAllQuerySyncSettings(false); - }); - - after(async () => { - await dashboardControls.updateAllQuerySyncSettings(true); - }); - - it('Does not apply query to options list control', async () => { - await queryBar.setQuery('isDog : true '); - await queryBar.submitQuery(); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - await ensureAvailableOptionsEql(allAvailableOptions); + describe('Selections made in control apply to dashboard', async () => { + it('Shows available options in options list', async () => { await queryBar.setQuery(''); await queryBar.submitQuery(); - }); - - it('Does not apply filters to options list control', async () => { - await filterBar.addFilter('sound.keyword', 'is one of', ['bark', 'bow ow ow', 'ruff']); - await dashboard.waitForRenderComplete(); - await header.waitUntilLoadingHasFinished(); - await ensureAvailableOptionsEql(allAvailableOptions); - await filterBar.removeAllFilters(); - }); - - it('Does not apply time range to options list control', async () => { - // set time range to time with no documents - await timePicker.setAbsoluteRange( - 'Jan 1, 2017 @ 00:00:00.000', - 'Jan 1, 2017 @ 00:00:00.000' - ); await dashboard.waitForRenderComplete(); await header.waitUntilLoadingHasFinished(); - await ensureAvailableOptionsEql(allAvailableOptions); - await timePicker.setDefaultDataRange(); - }); - }); - - describe('Selections made in control apply to dashboard', async () => { - it('Shows available options in options list', async () => { - await ensureAvailableOptionsEql(allAvailableOptions); + await retry.try(async () => { + await ensureAvailableOptionsEql(allAvailableOptions); + }); }); it('Can search options list for available options', async () => { @@ -315,6 +288,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); }); + it('Can search options list for available options case insensitive', async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSearchForOption('MEO'); + await ensureAvailableOptionsEql(['meow'], true); + await dashboardControls.optionsListPopoverClearSearch(); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + it('Can select multiple available options', async () => { await dashboardControls.optionsListOpenPopover(controlId); await dashboardControls.optionsListPopoverSelectOption('hiss'); @@ -342,9 +323,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const selectionString = await dashboardControls.optionsListGetSelectionsString(controlId); expect(selectionString).to.be('hiss, grr'); - }); - after(async () => { await dashboardControls.optionsListOpenPopover(controlId); await dashboardControls.optionsListPopoverClearSelections(); await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); @@ -406,6 +385,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('Options List dashboard no validation', async () => { before(async () => { + await filterBar.removeAllFilters(); + await queryBar.clickQuerySubmitButton(); await dashboardControls.optionsListOpenPopover(controlId); await dashboardControls.optionsListPopoverSelectOption('meow'); await dashboardControls.optionsListPopoverSelectOption('bark'); @@ -431,6 +412,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { after(async () => { await filterBar.removeAllFilters(); + await queryBar.clickQuerySubmitButton(); await dashboardControls.clearAllControls(); }); }); diff --git a/test/functional/apps/dashboard_elements/controls/range_slider.ts b/test/functional/apps/dashboard_elements/controls/range_slider.ts index 1ce8f05cae1908b..b2d07e7a49489d1 100644 --- a/test/functional/apps/dashboard_elements/controls/range_slider.ts +++ b/test/functional/apps/dashboard_elements/controls/range_slider.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { RANGE_SLIDER_CONTROL } from '@kbn/controls-plugin/common'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; @@ -80,7 +81,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('create and edit', async () => { it('can create a new range slider control from a blank state', async () => { - await dashboardControls.createRangeSliderControl({ + await dashboardControls.createControl({ + controlType: RANGE_SLIDER_CONTROL, dataViewTitle: 'logstash-*', fieldName: 'bytes', width: 'small', @@ -89,7 +91,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('can add a second range list control with a non-default data view', async () => { - await dashboardControls.createRangeSliderControl({ + await dashboardControls.createControl({ + controlType: RANGE_SLIDER_CONTROL, dataViewTitle: 'kibana_sample_data_flights', fieldName: 'AvgTicketPrice', width: 'medium', @@ -204,7 +207,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('disables inputs when no data available', async () => { - await dashboardControls.createRangeSliderControl({ + await dashboardControls.createControl({ + controlType: RANGE_SLIDER_CONTROL, dataViewTitle: 'logstash-*', fieldName: 'bytes', width: 'small', diff --git a/test/functional/apps/dashboard_elements/controls/replace_controls.ts b/test/functional/apps/dashboard_elements/controls/replace_controls.ts new file mode 100644 index 000000000000000..f6af39990507727 --- /dev/null +++ b/test/functional/apps/dashboard_elements/controls/replace_controls.ts @@ -0,0 +1,139 @@ +/* + * 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 { + OPTIONS_LIST_CONTROL, + RANGE_SLIDER_CONTROL, + TIME_SLIDER_CONTROL, +} from '@kbn/controls-plugin/common'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + + const { dashboardControls, timePicker, common, dashboard } = getPageObjects([ + 'dashboardControls', + 'timePicker', + 'dashboard', + 'common', + 'header', + ]); + + const changeFieldType = async (newField: string) => { + const saveButton = await testSubjects.find('control-editor-save'); + expect(await saveButton.isEnabled()).to.be(false); + await dashboardControls.controlsEditorSetfield(newField); + expect(await saveButton.isEnabled()).to.be(true); + await dashboardControls.controlEditorSave(); + }; + + const replaceWithOptionsList = async (controlId: string) => { + await dashboardControls.controlEditorSetType(OPTIONS_LIST_CONTROL); + await changeFieldType('sound.keyword'); + await testSubjects.waitForEnabled(`optionsList-control-${controlId}`); + await dashboardControls.verifyControlType(controlId, 'optionsList-control'); + }; + + const replaceWithRangeSlider = async (controlId: string) => { + await dashboardControls.controlEditorSetType(RANGE_SLIDER_CONTROL); + await changeFieldType('weightLbs'); + await retry.try(async () => { + await dashboardControls.rangeSliderWaitForLoading(); + await dashboardControls.verifyControlType(controlId, 'range-slider-control'); + }); + }; + + const replaceWithTimeSlider = async (controlId: string) => { + await dashboardControls.controlEditorSetType(TIME_SLIDER_CONTROL); + await changeFieldType('@timestamp'); + await testSubjects.waitForDeleted('timeSlider-loading-spinner'); + await dashboardControls.verifyControlType(controlId, 'timeSlider'); + }; + + describe('Replacing controls', async () => { + let controlId: string; + + before(async () => { + await common.navigateToApp('dashboard'); + await dashboard.gotoDashboardLandingPage(); + await dashboard.clickNewDashboard(); + await timePicker.setDefaultDataRange(); + }); + + describe('Replace options list', async () => { + beforeEach(async () => { + await dashboardControls.clearAllControls(); + await dashboardControls.createControl({ + controlType: OPTIONS_LIST_CONTROL, + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + }); + controlId = (await dashboardControls.getAllControlIds())[0]; + await dashboardControls.editExistingControl(controlId); + }); + + it('with range slider', async () => { + await replaceWithRangeSlider(controlId); + }); + + /** Because the time slider is temporarily disabled as of https://github.com/elastic/kibana/pull/130978, + ** I simply skipped all time slider tests for now :) **/ + it.skip('with time slider', async () => { + await replaceWithTimeSlider(controlId); + }); + }); + + describe('Replace range slider', async () => { + beforeEach(async () => { + await dashboardControls.clearAllControls(); + await dashboardControls.createControl({ + controlType: RANGE_SLIDER_CONTROL, + dataViewTitle: 'animals-*', + fieldName: 'weightLbs', + }); + await dashboardControls.rangeSliderWaitForLoading(); + controlId = (await dashboardControls.getAllControlIds())[0]; + await dashboardControls.editExistingControl(controlId); + }); + + it('with options list', async () => { + await replaceWithOptionsList(controlId); + }); + + it.skip('with time slider', async () => { + await replaceWithTimeSlider(controlId); + }); + }); + + describe.skip('Replace time slider', async () => { + beforeEach(async () => { + await dashboardControls.clearAllControls(); + await dashboardControls.createControl({ + controlType: TIME_SLIDER_CONTROL, + dataViewTitle: 'animals-*', + fieldName: '@timestamp', + }); + await testSubjects.waitForDeleted('timeSlider-loading-spinner'); + controlId = (await dashboardControls.getAllControlIds())[0]; + await dashboardControls.editExistingControl(controlId); + }); + + it('with options list', async () => { + await replaceWithOptionsList(controlId); + }); + + it('with range slider', async () => { + await replaceWithRangeSlider(controlId); + }); + }); + }); +} diff --git a/test/functional/apps/discover/_indexpattern_without_timefield.ts b/test/functional/apps/discover/_indexpattern_without_timefield.ts index 2d5892fa6e6cac4..6c936f63e999d44 100644 --- a/test/functional/apps/discover/_indexpattern_without_timefield.ts +++ b/test/functional/apps/discover/_indexpattern_without_timefield.ts @@ -91,7 +91,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await browser.goBack(); await PageObjects.discover.waitForDocTableLoadingComplete(); return ( - (await testSubjects.getVisibleText('indexPattern-switch-link')) === 'without-timefield' + (await testSubjects.getVisibleText('discover-dataView-switch-link')) === + 'without-timefield' ); } ); diff --git a/test/functional/apps/discover/_saved_queries.ts b/test/functional/apps/discover/_saved_queries.ts index 79d49131df1387b..d56b5032a430b8c 100644 --- a/test/functional/apps/discover/_saved_queries.ts +++ b/test/functional/apps/discover/_saved_queries.ts @@ -144,12 +144,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('saved query management component functionality', function () { before(async () => await setUpQueriesWithFilters()); - it('should show the saved query management component when there are no saved queries', async () => { + it('should show the saved query management load button as disabled when there are no saved queries', async () => { await savedQueryManagementComponent.openSavedQueryManagementComponent(); - const descriptionText = await testSubjects.getVisibleText('saved-query-management-popover'); - expect(descriptionText).to.eql( - 'Saved Queries\nThere are no saved queries. Save query text and filters that you want to use again.\nSave current query' - ); + const loadFilterSetBtn = await testSubjects.find('saved-query-management-load-button'); + const isDisabled = await loadFilterSetBtn.getAttribute('disabled'); + expect(isDisabled).to.equal('true'); }); it('should allow a query to be saved via the saved objects management component', async () => { @@ -189,9 +188,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('allows saving changes to a currently loaded query via the saved query management component', async () => { + await savedQueryManagementComponent.closeSavedQueryManagementComponent(); await queryBar.setQuery('response:404'); await savedQueryManagementComponent.updateCurrentlyLoadedQuery('OkResponse', false, false); await savedQueryManagementComponent.savedQueryExistOrFail('OkResponse'); + const contextMenuPanelTitleButton = await testSubjects.exists( + 'contextMenuPanelTitleButton' + ); + if (contextMenuPanelTitleButton) { + await testSubjects.click('contextMenuPanelTitleButton'); + } await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); expect(await queryBar.getQueryString()).to.eql(''); await savedQueryManagementComponent.loadSavedQuery('OkResponse'); @@ -199,9 +205,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('allows saving the currently loaded query as a new query', async () => { + await queryBar.setQuery('response:400'); await savedQueryManagementComponent.saveCurrentlyLoadedAsNewQuery( 'OkResponseCopy', - '200 responses', + '400 responses', false, false ); @@ -215,6 +222,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('does not allow saving a query with a non-unique name', async () => { + // this check allows this test to run stand alone, also should fix occacional flakiness + const savedQueryExists = await savedQueryManagementComponent.savedQueryExist('OkResponse'); + if (!savedQueryExists) { + await savedQueryManagementComponent.saveNewQuery( + 'OkResponse', + '200 responses for .jpg over 24 hours', + true, + true + ); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + } + await queryBar.setQuery('response:400'); await savedQueryManagementComponent.saveNewQueryWithNameError('OkResponse'); }); @@ -232,17 +251,22 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('allows clearing if non default language was remembered in localstorage', async () => { + await savedQueryManagementComponent.openSavedQueryManagementComponent(); await queryBar.switchQueryLanguage('lucene'); await PageObjects.common.navigateToApp('discover'); // makes sure discovered is reloaded without any state in url + await savedQueryManagementComponent.openSavedQueryManagementComponent(); await queryBar.expectQueryLanguageOrFail('lucene'); // make sure lucene is remembered after refresh (comes from localstorage) await savedQueryManagementComponent.loadSavedQuery('OkResponse'); + await savedQueryManagementComponent.openSavedQueryManagementComponent(); await queryBar.expectQueryLanguageOrFail('kql'); await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + await savedQueryManagementComponent.openSavedQueryManagementComponent(); await queryBar.expectQueryLanguageOrFail('lucene'); }); it('changing language removes saved query', async () => { await savedQueryManagementComponent.loadSavedQuery('OkResponse'); + await savedQueryManagementComponent.openSavedQueryManagementComponent(); await queryBar.switchQueryLanguage('lucene'); expect(await queryBar.getQueryString()).to.eql(''); }); diff --git a/test/functional/apps/getting_started/_shakespeare.ts b/test/functional/apps/getting_started/_shakespeare.ts index 426713c912e88e3..8cdf4a5f2ff76e9 100644 --- a/test/functional/apps/getting_started/_shakespeare.ts +++ b/test/functional/apps/getting_started/_shakespeare.ts @@ -39,7 +39,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // order they are added. let aggIndex = 1; // Used to track flag before and after reset - let isNewChartsLibraryEnabled = false; + let isNewChartsLibraryEnabled = true; before(async function () { log.debug( @@ -56,9 +56,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'test/functional/fixtures/es_archiver/getting_started/shakespeare' ); - if (isNewChartsLibraryEnabled) { + if (!isNewChartsLibraryEnabled) { await kibanaServer.uiSettings.update({ - 'visualization:visualize:legacyPieChartsLibrary': false, + 'visualization:visualize:legacyPieChartsLibrary': true, }); await browser.refresh(); } diff --git a/test/functional/apps/getting_started/index.ts b/test/functional/apps/getting_started/index.ts index a0506ce52e0283d..dfda371c3eedf9e 100644 --- a/test/functional/apps/getting_started/index.ts +++ b/test/functional/apps/getting_started/index.ts @@ -18,17 +18,17 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { }); // TODO: Remove when vislib is removed - describe('new charts library', function () { + describe('old charts library', function () { before(async () => { await kibanaServer.uiSettings.update({ - 'visualization:visualize:legacyPieChartsLibrary': false, + 'visualization:visualize:legacyPieChartsLibrary': true, }); await browser.refresh(); }); after(async () => { await kibanaServer.uiSettings.update({ - 'visualization:visualize:legacyPieChartsLibrary': true, + 'visualization:visualize:legacyPieChartsLibrary': false, }); await browser.refresh(); }); @@ -36,7 +36,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_shakespeare')); }); - describe('', () => { + describe('new charts library', () => { loadTestFile(require.resolve('./_shakespeare')); }); }); diff --git a/test/functional/apps/home/_navigation.ts b/test/functional/apps/home/_navigation.ts index 016cead53f0c438..1d9d02d5e94b587 100644 --- a/test/functional/apps/home/_navigation.ts +++ b/test/functional/apps/home/_navigation.ts @@ -11,7 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const browser = getService('browser'); - const PageObjects = getPageObjects(['common', 'header', 'home', 'timePicker']); + const PageObjects = getPageObjects(['common', 'header', 'home', 'timePicker', 'unifiedSearch']); const appsMenu = getService('appsMenu'); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); @@ -37,6 +37,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { // Navigate to discover app await appsMenu.clickLink('Discover'); + await PageObjects.unifiedSearch.closeTourPopoverByLocalStorage(); const discoverUrl = await browser.getCurrentUrl(); await PageObjects.timePicker.setDefaultAbsoluteRange(); const modifiedTimeDiscoverUrl = await browser.getCurrentUrl(); diff --git a/test/functional/apps/visualize/group2/_gauge_chart.ts b/test/functional/apps/visualize/group2/_gauge_chart.ts index 2c20c913b4d16d8..08425fcd78b5f91 100644 --- a/test/functional/apps/visualize/group2/_gauge_chart.ts +++ b/test/functional/apps/visualize/group2/_gauge_chart.ts @@ -102,6 +102,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should show correct values for fields with fieldFormatters', async () => { + await filterBar.removeAllFilters(); const expectedTexts = ['2,904', 'win 8: Count', '0B', 'win 8: Min bytes']; await PageObjects.visEditor.selectAggregation('Terms'); @@ -117,8 +118,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(expectedTexts).to.eql(metricValue); }); }); - - afterEach(async () => await filterBar.removeAllFilters()); }); }); } diff --git a/test/functional/apps/visualize/group2/_metric_chart.ts b/test/functional/apps/visualize/group2/_metric_chart.ts index b797ccb6303637d..d28835ea556e3d3 100644 --- a/test/functional/apps/visualize/group2/_metric_chart.ts +++ b/test/functional/apps/visualize/group2/_metric_chart.ts @@ -171,14 +171,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); describe('with filters', function () { - it('should prevent filtering without buckets', async function () { + it('should allow filtering without buckets', async function () { let filterCount = 0; await retry.try(async function tryingForTime() { // click first metric bucket await PageObjects.visEditor.clickMetricByIndex(0); filterCount = await filterBar.getFilterCount(); }); - expect(filterCount).to.equal(0); + await filterBar.removeAllFilters(); + expect(filterCount).to.equal(1); }); it('should allow filtering with buckets', async function () { diff --git a/test/functional/apps/visualize/group3/_pie_chart.ts b/test/functional/apps/visualize/group3/_pie_chart.ts index 23b008c690cba1b..e971daa18c8cb3f 100644 --- a/test/functional/apps/visualize/group3/_pie_chart.ts +++ b/test/functional/apps/visualize/group3/_pie_chart.ts @@ -50,7 +50,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { log.debug('setNumericInterval 4000'); await PageObjects.visEditor.setInterval('40000', { type: 'numeric' }); log.debug('clickGo'); - await PageObjects.visEditor.clickGo(); + await PageObjects.visEditor.clickGo(isNewChartsLibraryEnabled); }); it('should save and load', async function () { @@ -65,7 +65,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should show 10 slices in pie chart', async function () { - pieChart.expectPieSliceCount(10); + pieChart.expectPieSliceCount(10, isNewChartsLibraryEnabled); }); it('should show correct data', async function () { @@ -105,8 +105,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.toggleOtherBucket(2); await PageObjects.visEditor.toggleMissingBucket(2); log.debug('clickGo'); - await PageObjects.visEditor.clickGo(); - await pieChart.expectPieChartLabels(expectedTableData); + await PageObjects.visEditor.clickGo(isNewChartsLibraryEnabled); + await pieChart.expectPieChartLabels(expectedTableData, isNewChartsLibraryEnabled); }); it('should apply correct filter on other bucket', async () => { @@ -114,7 +114,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await pieChart.filterOnPieSlice('Other'); await PageObjects.visChart.waitForVisualization(); - await pieChart.expectPieChartLabels(expectedTableData); + await pieChart.expectPieChartLabels(expectedTableData, isNewChartsLibraryEnabled); await filterBar.removeFilter('machine.os.raw'); await PageObjects.visChart.waitForVisualization(); }); @@ -124,7 +124,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visChart.filterLegend('Other'); await PageObjects.visChart.waitForVisualization(); - await pieChart.expectPieChartLabels(expectedTableData); + await pieChart.expectPieChartLabels(expectedTableData, isNewChartsLibraryEnabled); await filterBar.removeFilter('machine.os.raw'); await PageObjects.visChart.waitForVisualization(); }); @@ -183,8 +183,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.toggleOtherBucket(3); await PageObjects.visEditor.toggleMissingBucket(3); log.debug('clickGo'); - await PageObjects.visEditor.clickGo(); - await pieChart.expectPieChartLabels(expectedTableData); + await PageObjects.visEditor.clickGo(isNewChartsLibraryEnabled); + await pieChart.expectPieChartLabels(expectedTableData, isNewChartsLibraryEnabled); }); }); @@ -201,9 +201,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.selectAggregation('Terms'); await PageObjects.visEditor.selectField('machine.os.raw'); await PageObjects.visEditor.toggleDisabledAgg(2); - await PageObjects.visEditor.clickGo(); + await PageObjects.visEditor.clickGo(isNewChartsLibraryEnabled); - await pieChart.expectPieChartLabels(expectedTableData); + await pieChart.expectPieChartLabels(expectedTableData, isNewChartsLibraryEnabled); }); it('should correctly save disabled agg', async () => { @@ -213,12 +213,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visChart.waitForRenderingCount(); const expectedTableData = ['ios', 'osx', 'win 7', 'win 8', 'win xp']; - await pieChart.expectPieChartLabels(expectedTableData); + await pieChart.expectPieChartLabels(expectedTableData, isNewChartsLibraryEnabled); }); it('should show correct result when agg is re-enabled', async () => { await PageObjects.visEditor.toggleDisabledAgg(2); - await PageObjects.visEditor.clickGo(); + await PageObjects.visEditor.clickGo(isNewChartsLibraryEnabled); const expectedTableData = [ '0', @@ -283,7 +283,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'osx', ].sort(); - await pieChart.expectPieChartLabels(expectedTableData); + await pieChart.expectPieChartLabels(expectedTableData, isNewChartsLibraryEnabled); }); }); @@ -304,7 +304,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.addNewFilterAggregation(); log.debug('Set the 2nd filter value'); await PageObjects.visEditor.setFilterAggregationValue('geo.dest:"CN"', 1); - await PageObjects.visEditor.clickGo(); + await PageObjects.visEditor.clickGo(isNewChartsLibraryEnabled); const emptyFromTime = 'Sep 19, 2016 @ 06:31:44.000'; const emptyToTime = 'Sep 23, 2016 @ 18:31:44.000'; log.debug( @@ -346,7 +346,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.clickBucket('Split slices'); await PageObjects.visEditor.selectAggregation('Terms'); await PageObjects.visEditor.selectField('geo.dest'); - await PageObjects.visEditor.clickGo(); + await PageObjects.visEditor.clickGo(isNewChartsLibraryEnabled); }); it('should show correct chart', async () => { @@ -435,16 +435,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { '360,000', 'CN', ].sort(); - if (await PageObjects.visChart.isNewLibraryChart('partitionVisChart')) { + if (isNewChartsLibraryEnabled) { await PageObjects.visEditor.clickOptionsTab(); await PageObjects.visEditor.togglePieLegend(); await PageObjects.visEditor.togglePieNestedLegend(); await PageObjects.visEditor.clickDataTab(); - await PageObjects.visEditor.clickGo(); + await PageObjects.visEditor.clickGo(isNewChartsLibraryEnabled); } await PageObjects.visChart.filterLegend('CN'); await PageObjects.visChart.waitForVisualization(); - await pieChart.expectPieChartLabels(expectedTableData); + await pieChart.expectPieChartLabels(expectedTableData, isNewChartsLibraryEnabled); await filterBar.removeFilter('geo.dest'); await PageObjects.visChart.waitForVisualization(); }); @@ -474,7 +474,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.selectAggregation('Filters'); log.debug('Set the 1st filter value of the aggregation id 3'); await PageObjects.visEditor.setFilterAggregationValue('geo.dest:"UX"', 0, 3); - await PageObjects.visEditor.clickGo(); + await PageObjects.visEditor.clickGo(isNewChartsLibraryEnabled); const legends = await PageObjects.visChart.getLegendEntries(); const expectedLegends = ['geo.dest:"US"', 'geo.dest:"UX"']; expect(legends).to.eql(expectedLegends); @@ -496,7 +496,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visEditor.clickBucket('Split slices'); await PageObjects.visEditor.selectAggregation('Terms'); await PageObjects.visEditor.selectField('geo.src'); - await PageObjects.visEditor.clickGo(); + await PageObjects.visEditor.clickGo(isNewChartsLibraryEnabled); }); it('shows correct split chart', async () => { diff --git a/test/functional/apps/visualize/group3/index.ts b/test/functional/apps/visualize/group3/index.ts index 93eff60575cb3bc..e1f86ca1798a611 100644 --- a/test/functional/apps/visualize/group3/index.ts +++ b/test/functional/apps/visualize/group3/index.ts @@ -12,6 +12,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { const browser = getService('browser'); const log = getService('log'); const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); describe('visualize app', () => { before(async () => { @@ -21,13 +22,25 @@ 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'); + + await kibanaServer.uiSettings.update({ + 'visualization:visualize:legacyPieChartsLibrary': true, + }); + await browser.refresh(); + }); + + after(async () => { + await kibanaServer.uiSettings.update({ + 'visualization:visualize:legacyPieChartsLibrary': false, + }); + await browser.refresh(); }); - loadTestFile(require.resolve('./_pie_chart')); loadTestFile(require.resolve('./_shared_item')); loadTestFile(require.resolve('./_lab_mode')); loadTestFile(require.resolve('./_linked_saved_searches')); loadTestFile(require.resolve('./_visualize_listing')); loadTestFile(require.resolve('./_add_to_dashboard.ts')); + loadTestFile(require.resolve('./_pie_chart')); }); } diff --git a/test/functional/apps/visualize/group6/_vega_chart.ts b/test/functional/apps/visualize/group6/_vega_chart.ts index 78a370523071bb1..1d802065ad13780 100644 --- a/test/functional/apps/visualize/group6/_vega_chart.ts +++ b/test/functional/apps/visualize/group6/_vega_chart.ts @@ -220,7 +220,10 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { describe('Vega extension functions', () => { beforeEach(async () => { - await filterBar.removeAllFilters(); + const filtersCount = await filterBar.getFilterCount(); + if (filtersCount > 0) { + await filterBar.removeAllFilters(); + } }); const fillSpecAndGo = async (newSpec: string) => { diff --git a/test/functional/apps/visualize/replaced_vislib_chart_types/index.ts b/test/functional/apps/visualize/replaced_vislib_chart_types/index.ts index 5794edef68555bf..d7f674753058d83 100644 --- a/test/functional/apps/visualize/replaced_vislib_chart_types/index.ts +++ b/test/functional/apps/visualize/replaced_vislib_chart_types/index.ts @@ -27,7 +27,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { before(async () => { await kibanaServer.uiSettings.update({ - 'visualization:visualize:legacyPieChartsLibrary': false, 'visualization:visualize:legacyHeatmapChartsLibrary': false, }); await browser.refresh(); @@ -35,7 +34,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { after(async () => { await kibanaServer.uiSettings.update({ - 'visualization:visualize:legacyPieChartsLibrary': true, 'visualization:visualize:legacyHeatmapChartsLibrary': true, }); await browser.refresh(); diff --git a/test/functional/config.base.js b/test/functional/config.base.js index 40b50da505951ff..a7ee20631efa34e 100644 --- a/test/functional/config.base.js +++ b/test/functional/config.base.js @@ -39,7 +39,6 @@ export default async function ({ readConfigFile }) { defaults: { 'accessibility:disableAnimations': true, 'dateFormat:tz': 'UTC', - 'visualization:visualize:legacyPieChartsLibrary': true, 'visualization:useLegacyTimeAxis': true, }, }, diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index 28ac88674b4a674..83bb0567a02f5fb 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -8,8 +8,6 @@ import { setTimeout as setTimeoutAsync } from 'timers/promises'; import expect from '@kbn/expect'; -// @ts-ignore -import fetch from 'node-fetch'; import { getUrl } from '@kbn/test'; import moment from 'moment'; import { FtrService } from '../ftr_provider_context'; @@ -281,6 +279,7 @@ export class CommonPageObject extends FtrService { } if (appName === 'discover') { await this.browser.setLocalStorageItem('data.autocompleteFtuePopover', 'true'); + await this.browser.setLocalStorageItem('data.newDataViewMenu', 'true'); } return currentUrl; }); diff --git a/test/functional/page_objects/dashboard_page_controls.ts b/test/functional/page_objects/dashboard_page_controls.ts index 179e27d09cf550a..5d3b4d3a2b6cdcb 100644 --- a/test/functional/page_objects/dashboard_page_controls.ts +++ b/test/functional/page_objects/dashboard_page_controls.ts @@ -7,11 +7,7 @@ */ import expect from '@kbn/expect'; -import { - OPTIONS_LIST_CONTROL, - ControlWidth, - RANGE_SLIDER_CONTROL, -} from '@kbn/controls-plugin/common'; +import { OPTIONS_LIST_CONTROL, ControlWidth } from '@kbn/controls-plugin/common'; import { ControlGroupChainingSystem } from '@kbn/controls-plugin/common/control_group/types'; import { WebElementWrapper } from '../services/lib/web_element_wrapper'; @@ -82,7 +78,7 @@ export class DashboardPageControls extends FtrService { await this.retry.try(async () => { await this.testSubjects.existOrFail('control-editor-flyout'); }); - await this.testSubjects.click(`create-${type}-control`); + await this.controlEditorSetType(type); } /* ----------------------------------------------------------- @@ -234,6 +230,30 @@ export class DashboardPageControls extends FtrService { return controlElement; } + public async createControl({ + controlType, + dataViewTitle, + fieldName, + width, + title, + }: { + controlType: string; + title?: string; + fieldName: string; + width?: ControlWidth; + dataViewTitle?: string; + }) { + this.log.debug(`Creating ${controlType} control ${title ?? fieldName}`); + await this.openCreateControlFlyout(controlType); + + if (dataViewTitle) await this.controlsEditorSetDataView(dataViewTitle); + if (fieldName) await this.controlsEditorSetfield(fieldName); + if (title) await this.controlEditorSetTitle(title); + if (width) await this.controlEditorSetWidth(width); + + await this.controlEditorSave(); + } + public async hoverOverExistingControl(controlId: string) { const elementToHover = await this.getControlElementById(controlId); await this.retry.try(async () => { @@ -263,6 +283,14 @@ export class DashboardPageControls extends FtrService { await this.common.clickConfirmOnModal(); } + public async verifyControlType(controlId: string, expectedType: string) { + const controlButton = await this.find.byXPath( + `//div[@id='controlFrame--${controlId}']//button` + ); + const testSubj = await controlButton.getAttribute('data-test-subj'); + expect(testSubj).to.equal(`${expectedType}-${controlId}`); + } + // Options list functions public async optionsListGetSelectionsString(controlId: string) { this.log.debug(`Getting selections string for Options List: ${controlId}`); @@ -376,29 +404,12 @@ export class DashboardPageControls extends FtrService { await this.testSubjects.click(`field-picker-select-${fieldName}`); } - // Options List editor functions - public async createOptionsListControl({ - dataViewTitle, - fieldName, - width, - title, - }: { - title?: string; - fieldName: string; - width?: ControlWidth; - dataViewTitle?: string; - }) { - this.log.debug(`Creating options list control ${title ?? fieldName}`); - await this.openCreateControlFlyout(OPTIONS_LIST_CONTROL); - - if (dataViewTitle) await this.controlsEditorSetDataView(dataViewTitle); - if (fieldName) await this.controlsEditorSetfield(fieldName); - if (title) await this.controlEditorSetTitle(title); - if (width) await this.controlEditorSetWidth(width); - - await this.controlEditorSave(); + public async controlEditorSetType(type: string) { + this.log.debug(`Setting control type to ${type}`); + await this.testSubjects.click(`create-${type}-control`); } + // Options List editor functions public async optionsListEditorGetCurrentDataView(openAndCloseFlyout?: boolean) { if (openAndCloseFlyout) { await this.openCreateControlFlyout(OPTIONS_LIST_CONTROL); @@ -476,27 +487,4 @@ export class DashboardPageControls extends FtrService { await this.rangeSliderPopoverAssertOpen(); await this.testSubjects.click('rangeSlider__clearRangeButton'); } - - // Range slider editor functions - public async createRangeSliderControl({ - dataViewTitle, - fieldName, - width, - title, - }: { - title?: string; - fieldName: string; - width?: ControlWidth; - dataViewTitle?: string; - }) { - this.log.debug(`Creating range slider control ${title ?? fieldName}`); - await this.openCreateControlFlyout(RANGE_SLIDER_CONTROL); - - if (dataViewTitle) await this.controlsEditorSetDataView(dataViewTitle); - if (fieldName) await this.controlsEditorSetfield(fieldName); - if (title) await this.controlEditorSetTitle(title); - if (width) await this.controlEditorSetWidth(width); - - await this.controlEditorSave(); - } } diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index ce25370493823c9..5691b4f5609c792 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -22,6 +22,9 @@ export class DiscoverPageObject extends FtrService { private readonly config = this.ctx.getService('config'); private readonly dataGrid = this.ctx.getService('dataGrid'); private readonly kibanaServer = this.ctx.getService('kibanaServer'); + private readonly queryBar = this.ctx.getService('queryBar'); + + private readonly unifiedSearch = this.ctx.getPageObject('unifiedSearch'); private readonly defaultFindTimeout = this.config.get('timeouts.find'); @@ -365,8 +368,7 @@ export class DiscoverPageObject extends FtrService { public async clickIndexPatternActions() { await this.retry.try(async () => { - await this.testSubjects.click('discoverIndexPatternActions'); - await this.testSubjects.existOrFail('discover-addRuntimeField-popover'); + await this.testSubjects.click('discover-dataView-switch-link'); }); } @@ -494,7 +496,7 @@ export class DiscoverPageObject extends FtrService { } public async selectIndexPattern(indexPattern: string) { - await this.testSubjects.click('indexPattern-switch-link'); + await this.testSubjects.click('discover-dataView-switch-link'); await this.find.setValue('[data-test-subj="indexPattern-switcher"] input', indexPattern); await this.find.clickByCssSelector( `[data-test-subj="indexPattern-switcher"] [title="${indexPattern}"]` @@ -557,6 +559,7 @@ export class DiscoverPageObject extends FtrService { await this.retry.waitFor('Discover app on screen', async () => { return await this.isDiscoverAppOnScreen(); }); + await this.unifiedSearch.closeTourPopoverByLocalStorage(); } public async showAllFilterActions() { @@ -564,10 +567,13 @@ export class DiscoverPageObject extends FtrService { } public async clickSavedQueriesPopOver() { - await this.testSubjects.click('saved-query-management-popover-button'); + await this.testSubjects.click('showQueryBarMenu'); } public async clickCurrentSavedQuery() { + await this.queryBar.setQuery('Cancelled : true'); + await this.queryBar.clickQuerySubmitButton(); + await this.testSubjects.click('showQueryBarMenu'); await this.testSubjects.click('saved-query-management-save-button'); } @@ -630,7 +636,7 @@ export class DiscoverPageObject extends FtrService { public async getCurrentlySelectedDataView() { await this.testSubjects.existOrFail('discover-sidebar'); - const button = await this.testSubjects.find('indexPattern-switch-link'); + const button = await this.testSubjects.find('discover-dataView-switch-link'); return button.getAttribute('title'); } } diff --git a/test/functional/page_objects/index.ts b/test/functional/page_objects/index.ts index 826c4b78d1d0f13..bdfe91efef9007a 100644 --- a/test/functional/page_objects/index.ts +++ b/test/functional/page_objects/index.ts @@ -31,6 +31,7 @@ import { SavedObjectsPageObject } from './management/saved_objects_page'; import { LegacyDataTableVisPageObject } from './legacy/data_table_vis'; import { IndexPatternFieldEditorPageObject } from './management/indexpattern_field_editor_page'; import { DashboardPageControls } from './dashboard_page_controls'; +import { UnifiedSearchPageObject } from './unified_search_page'; export const pageObjects = { common: CommonPageObject, @@ -58,4 +59,5 @@ export const pageObjects = { vegaChart: VegaChartPageObject, savedObjects: SavedObjectsPageObject, indexPatternFieldEditorObjects: IndexPatternFieldEditorPageObject, + unifiedSearch: UnifiedSearchPageObject, }; diff --git a/test/functional/page_objects/unified_search_page.ts b/test/functional/page_objects/unified_search_page.ts new file mode 100644 index 000000000000000..b1bcd0662f77e0e --- /dev/null +++ b/test/functional/page_objects/unified_search_page.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { FtrService } from '../ftr_provider_context'; + +export class UnifiedSearchPageObject extends FtrService { + private readonly browser = this.ctx.getService('browser'); + private readonly testSubjects = this.ctx.getService('testSubjects'); + + public async closeTour() { + const tourPopoverIsOpen = await this.testSubjects.exists('dataViewPickerTourLink'); + if (tourPopoverIsOpen) { + await this.testSubjects.click('dataViewPickerTourLink'); + } + } + + public async closeTourPopoverByLocalStorage() { + await this.browser.setLocalStorageItem('data.newDataViewMenu', 'true'); + await this.browser.refresh(); + } +} diff --git a/test/functional/page_objects/visual_builder_page.ts b/test/functional/page_objects/visual_builder_page.ts index fbf6b96b3136d35..f96e4088da78fb3 100644 --- a/test/functional/page_objects/visual_builder_page.ts +++ b/test/functional/page_objects/visual_builder_page.ts @@ -374,11 +374,13 @@ export class VisualBuilderPageObject extends FtrService { } public async getTopNLabel() { + await this.visChart.waitForVisualizationRenderingStabilized(); const topNLabel = await this.find.byCssSelector('.tvbVisTopN__label'); return await topNLabel.getVisibleText(); } public async getTopNCount() { + await this.visChart.waitForVisualizationRenderingStabilized(); const gaugeCount = await this.find.byCssSelector('.tvbVisTopN__value'); return await gaugeCount.getVisibleText(); } diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index 3eec4e2ce1a2bfc..93e811ef5b6a26a 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -32,6 +32,10 @@ export class VisualizeChartPageObject extends FtrService { return await this.elasticChart.getChartDebugData(chartSelector); } + public async getAllESChartsDebugDataByTestSubj(chartSelector: string) { + return await this.elasticChart.getAllChartsDebugDataByTestSubj(chartSelector); + } + /** * Is new charts library advanced setting enabled */ diff --git a/test/functional/page_objects/visualize_page.ts b/test/functional/page_objects/visualize_page.ts index 20aec8ba5d9842d..e087d50f21003a2 100644 --- a/test/functional/page_objects/visualize_page.ts +++ b/test/functional/page_objects/visualize_page.ts @@ -39,6 +39,7 @@ export class VisualizePageObject extends FtrService { private readonly elasticChart = this.ctx.getService('elasticChart'); private readonly common = this.ctx.getPageObject('common'); private readonly header = this.ctx.getPageObject('header'); + private readonly unifiedSearch = this.ctx.getPageObject('unifiedSearch'); private readonly visEditor = this.ctx.getPageObject('visEditor'); private readonly visChart = this.ctx.getPageObject('visChart'); @@ -154,6 +155,10 @@ export class VisualizePageObject extends FtrService { public async clickVisType(type: string) { await this.testSubjects.click(`visType-${type}`); await this.header.waitUntilLoadingHasFinished(); + + if (type === 'lens') { + await this.unifiedSearch.closeTour(); + } } public async clickAreaChart() { diff --git a/test/functional/screenshots/baseline/area_chart.png b/test/functional/screenshots/baseline/area_chart.png index 851f53499e94fb8..7bbaa256f036098 100644 Binary files a/test/functional/screenshots/baseline/area_chart.png and b/test/functional/screenshots/baseline/area_chart.png differ diff --git a/test/functional/services/combo_box.ts b/test/functional/services/combo_box.ts index 8b43364c23a2281..98b04f83aa47f85 100644 --- a/test/functional/services/combo_box.ts +++ b/test/functional/services/combo_box.ts @@ -8,8 +8,6 @@ import { FtrService } from '../ftr_provider_context'; import { WebElementWrapper } from './lib/web_element_wrapper'; -// @ts-ignore not supported yet -import { scrollIntoViewIfNecessary } from './lib/web_element_wrapper/scroll_into_view_if_necessary'; /** * wrapper around EuiComboBox interactions diff --git a/test/functional/services/common/failure_debugging.ts b/test/functional/services/common/failure_debugging.ts index 1d0b99ff26da9e5..30eaafdaff64a86 100644 --- a/test/functional/services/common/failure_debugging.ts +++ b/test/functional/services/common/failure_debugging.ts @@ -9,6 +9,7 @@ import { resolve } from 'path'; import { writeFile, mkdir } from 'fs'; import { promisify } from 'util'; +import Uuid from 'uuid'; import del from 'del'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -49,7 +50,10 @@ export async function FailureDebuggingProvider({ getService }: FtrProviderContex async function onFailure(_: any, test: Test) { // Replace characters in test names which can't be used in filenames, like * - const name = test.fullTitle().replace(/([^ a-zA-Z0-9-]+)/g, '_'); + let name = test.fullTitle().replace(/([^ a-zA-Z0-9-]+)/g, '_'); + if (name.length > 100) { + name = `truncated-${name.slice(-100)}-${Uuid.v4()}`; + } await Promise.all([screenshots.takeForFailure(name), logCurrentUrl(), savePageHtml(name)]); } diff --git a/test/functional/services/dashboard/expectations.ts b/test/functional/services/dashboard/expectations.ts index c56e7c1eae27efb..081e5cd5b85c04e 100644 --- a/test/functional/services/dashboard/expectations.ts +++ b/test/functional/services/dashboard/expectations.ts @@ -19,6 +19,7 @@ export class DashboardExpectService extends FtrService { private readonly dashboard = this.ctx.getPageObject('dashboard'); private readonly visChart = this.ctx.getPageObject('visChart'); + private readonly pieChart = this.ctx.getService('pieChart'); private readonly tagCloud = this.ctx.getPageObject('tagCloud'); private readonly findTimeout = 2500; @@ -39,11 +40,11 @@ export class DashboardExpectService extends FtrService { async selectedLegendColorCount(color: string, expectedCount: number) { this.log.debug(`DashboardExpect.selectedLegendColorCount(${color}, ${expectedCount})`); await this.retry.try(async () => { - const selectedLegendColor = await this.testSubjects.findAll( - `legendSelectedColor-${color}`, - this.findTimeout - ); - expect(selectedLegendColor.length).to.be(expectedCount); + const slicesColors = await this.pieChart.getAllPieSlicesColors(); + const selectedColors = slicesColors.filter((sliceColor) => { + return sliceColor === color; + }); + expect(selectedColors.length).to.be(expectedCount); }); } diff --git a/test/functional/services/dashboard/visualizations.ts b/test/functional/services/dashboard/visualizations.ts index 8688d375f7a7b9d..48828798a4efa35 100644 --- a/test/functional/services/dashboard/visualizations.ts +++ b/test/functional/services/dashboard/visualizations.ts @@ -17,6 +17,7 @@ export class DashboardVisualizationsService extends FtrService { private readonly visualize = this.ctx.getPageObject('visualize'); private readonly visEditor = this.ctx.getPageObject('visEditor'); private readonly header = this.ctx.getPageObject('header'); + private readonly unifiedSearch = this.ctx.getPageObject('unifiedSearch'); private readonly discover = this.ctx.getPageObject('discover'); private readonly timePicker = this.ctx.getPageObject('timePicker'); @@ -43,6 +44,7 @@ export class DashboardVisualizationsService extends FtrService { }) { this.log.debug(`createSavedSearch(${name})`); await this.header.clickDiscover(true); + await this.unifiedSearch.closeTourPopoverByLocalStorage(); await this.timePicker.setHistoricalDataRange(); if (query) { diff --git a/test/functional/services/filter_bar.ts b/test/functional/services/filter_bar.ts index eee1a1027f54193..7178013d5b9fd66 100644 --- a/test/functional/services/filter_bar.ts +++ b/test/functional/services/filter_bar.ts @@ -64,8 +64,8 @@ export class FilterBarService extends FtrService { * Removes all filters */ public async removeAllFilters(): Promise { - await this.testSubjects.click('showFilterActions'); - await this.testSubjects.click('removeAllFilters'); + await this.testSubjects.click('showQueryBarMenu'); + await this.testSubjects.click('filter-sets-removeAllFilters'); await this.header.waitUntilLoadingHasFinished(); await this.common.waitUntilUrlIncludes('filters:!()'); } diff --git a/test/functional/services/query_bar.ts b/test/functional/services/query_bar.ts index ec5fc039101a5ca..ca6c161accc3961 100644 --- a/test/functional/services/query_bar.ts +++ b/test/functional/services/query_bar.ts @@ -16,7 +16,6 @@ export class QueryBarService extends FtrService { private readonly common = this.ctx.getPageObject('common'); private readonly header = this.ctx.getPageObject('header'); private readonly find = this.ctx.getService('find'); - private readonly browser = this.ctx.getService('browser'); async getQueryString(): Promise { return await this.testSubjects.getAttribute('queryInput', 'value'); @@ -60,20 +59,19 @@ export class QueryBarService extends FtrService { public async switchQueryLanguage(lang: 'kql' | 'lucene'): Promise { await this.testSubjects.click('switchQueryLanguageButton'); - const kqlToggle = await this.testSubjects.find('languageToggle'); - const currentLang = - (await kqlToggle.getAttribute('aria-checked')) === 'true' ? 'kql' : 'lucene'; - if (lang !== currentLang) { - await kqlToggle.click(); + await this.testSubjects.click(`${lang}LanguageMenuItem`); + const contextMenuPanelTitleButton = await this.testSubjects.exists( + 'contextMenuPanelTitleButton' + ); + if (contextMenuPanelTitleButton) { + await this.testSubjects.click('contextMenuPanelTitleButton'); } - - await this.browser.pressKeys(this.browser.keys.ESCAPE); // close popover await this.expectQueryLanguageOrFail(lang); // make sure lang is switched } public async expectQueryLanguageOrFail(lang: 'kql' | 'lucene'): Promise { const queryLanguageButton = await this.testSubjects.find('switchQueryLanguageButton'); - expect((await queryLanguageButton.getVisibleText()).toLowerCase()).to.eql(lang); + expect((await queryLanguageButton.getVisibleText()).toLowerCase()).to.eql(`language: ${lang}`); } /** diff --git a/test/functional/services/saved_query_management_component.ts b/test/functional/services/saved_query_management_component.ts index a216f8cb0469e53..7822ed8f77a897f 100644 --- a/test/functional/services/saved_query_management_component.ts +++ b/test/functional/services/saved_query_management_component.ts @@ -19,7 +19,7 @@ export class SavedQueryManagementComponentService extends FtrService { public async getCurrentlyLoadedQueryID() { await this.openSavedQueryManagementComponent(); try { - return await this.testSubjects.getVisibleText('~saved-query-list-item-selected'); + return await this.testSubjects.getVisibleText('savedQueryTitle'); } catch { return undefined; } @@ -53,7 +53,12 @@ export class SavedQueryManagementComponentService extends FtrService { return saveQueryFormSaveButtonStatus === false; }); - await this.testSubjects.click('savedQueryFormCancelButton'); + const contextMenuPanelTitleButton = await this.testSubjects.exists( + 'contextMenuPanelTitleButton' + ); + if (contextMenuPanelTitleButton) { + await this.testSubjects.click('contextMenuPanelTitleButton'); + } } public async saveCurrentlyLoadedAsNewQuery( @@ -63,7 +68,7 @@ export class SavedQueryManagementComponentService extends FtrService { includeTimeFilter: boolean ) { await this.openSavedQueryManagementComponent(); - await this.testSubjects.click('saved-query-management-save-as-new-button'); + await this.testSubjects.click('saved-query-management-save-button'); await this.submitSaveQueryForm(name, description, includeFilters, includeTimeFilter); } @@ -79,12 +84,12 @@ export class SavedQueryManagementComponentService extends FtrService { public async loadSavedQuery(title: string) { await this.openSavedQueryManagementComponent(); + await this.testSubjects.click('saved-query-management-load-button'); await this.testSubjects.click(`~load-saved-query-${title}-button`); + await this.testSubjects.click('saved-query-management-apply-changes-button'); await this.retry.try(async () => { await this.openSavedQueryManagementComponent(); - const selectedSavedQueryText = await this.testSubjects.getVisibleText( - '~saved-query-list-item-selected' - ); + const selectedSavedQueryText = await this.testSubjects.getVisibleText('savedQueryTitle'); expect(selectedSavedQueryText).to.eql(title); }); await this.closeSavedQueryManagementComponent(); @@ -92,13 +97,24 @@ export class SavedQueryManagementComponentService extends FtrService { public async deleteSavedQuery(title: string) { await this.openSavedQueryManagementComponent(); - await this.testSubjects.click(`~delete-saved-query-${title}-button`); + const shouldClickLoadMenu = await this.testSubjects.exists( + 'saved-query-management-load-button' + ); + if (shouldClickLoadMenu) { + await this.testSubjects.click('saved-query-management-load-button'); + } + await this.testSubjects.click(`~load-saved-query-${title}-button`); + await this.retry.waitFor('delete saved query', async () => { + await this.testSubjects.click(`delete-saved-query-${title}-button`); + const exists = await this.testSubjects.exists('confirmModalTitleText'); + return exists === true; + }); await this.common.clickConfirmOnModal(); } async clearCurrentlyLoadedQuery() { await this.openSavedQueryManagementComponent(); - await this.testSubjects.click('saved-query-management-clear-button'); + await this.testSubjects.click('filter-sets-removeAllFilters'); await this.closeSavedQueryManagementComponent(); const queryString = await this.queryBar.getQueryString(); expect(queryString).to.be.empty(); @@ -113,7 +129,6 @@ export class SavedQueryManagementComponentService extends FtrService { if (title) { await this.testSubjects.setValue('saveQueryFormTitle', title); } - await this.testSubjects.setValue('saveQueryFormDescription', description); const currentIncludeFiltersValue = (await this.testSubjects.getAttribute( @@ -138,6 +153,7 @@ export class SavedQueryManagementComponentService extends FtrService { async savedQueryExist(title: string) { await this.openSavedQueryManagementComponent(); + await this.testSubjects.click('saved-query-management-load-button'); const exists = await this.testSubjects.exists(`~load-saved-query-${title}-button`); await this.closeSavedQueryManagementComponent(); return exists; @@ -145,6 +161,13 @@ export class SavedQueryManagementComponentService extends FtrService { async savedQueryExistOrFail(title: string) { await this.openSavedQueryManagementComponent(); + await this.retry.waitFor('load saved query', async () => { + const shouldClickLoadMenu = await this.testSubjects.exists( + 'saved-query-management-load-button' + ); + return shouldClickLoadMenu === true; + }); + await this.testSubjects.click('saved-query-management-load-button'); await this.testSubjects.existOrFail(`~load-saved-query-${title}-button`); } @@ -163,24 +186,19 @@ export class SavedQueryManagementComponentService extends FtrService { } async openSavedQueryManagementComponent() { - const isOpenAlready = await this.testSubjects.exists('saved-query-management-popover'); + const isOpenAlready = await this.testSubjects.exists('queryBarMenuPanel'); if (isOpenAlready) return; - await this.testSubjects.click('saved-query-management-popover-button'); - - await this.retry.waitFor('saved query management popover to have any text', async () => { - const queryText = await this.testSubjects.getVisibleText('saved-query-management-popover'); - return queryText.length > 0; - }); + await this.testSubjects.click('showQueryBarMenu'); } async closeSavedQueryManagementComponent() { - const isOpenAlready = await this.testSubjects.exists('saved-query-management-popover'); + const isOpenAlready = await this.testSubjects.exists('queryBarMenuPanel'); if (!isOpenAlready) return; await this.retry.try(async () => { - await this.testSubjects.click('saved-query-management-popover-button'); - await this.testSubjects.missingOrFail('saved-query-management-popover'); + await this.testSubjects.click('showQueryBarMenu'); + await this.testSubjects.missingOrFail('queryBarMenuPanel'); }); } @@ -197,7 +215,9 @@ export class SavedQueryManagementComponentService extends FtrService { async saveNewQueryMissingOrFail() { await this.openSavedQueryManagementComponent(); - await this.testSubjects.missingOrFail('saved-query-management-save-button'); + const saveFilterSetBtn = await this.testSubjects.find('saved-query-management-save-button'); + const isDisabled = await saveFilterSetBtn.getAttribute('disabled'); + expect(isDisabled).to.equal('true'); } async updateCurrentlyLoadedQueryMissingOrFail() { diff --git a/test/functional/services/visualizations/elastic_chart.ts b/test/functional/services/visualizations/elastic_chart.ts index b954b4ba03616ef..c3ba8697c2256b5 100644 --- a/test/functional/services/visualizations/elastic_chart.ts +++ b/test/functional/services/visualizations/elastic_chart.ts @@ -94,6 +94,11 @@ export class ElasticChartService extends FtrService { } } + public async getAllChartsDebugDataByTestSubj(dataTestSubj: string): Promise { + const charts = await this.testSubjects.findAll(dataTestSubj); + return charts; + } + private async getAllCharts(timeout?: number) { return await this.find.allByCssSelector('.echChart', timeout); } diff --git a/test/functional/services/visualizations/pie_chart.ts b/test/functional/services/visualizations/pie_chart.ts index 16133140e4abf05..0669bb6e91e52ef 100644 --- a/test/functional/services/visualizations/pie_chart.ts +++ b/test/functional/services/visualizations/pie_chart.ts @@ -35,7 +35,7 @@ export class PieChartService extends FtrService { if (name === 'Other') { sliceLabel = '__other__'; } - const pieSlice = slices.find((slice) => slice.name === sliceLabel); + const pieSlice = slices.find((slice) => String(slice.name) === sliceLabel); const pie = await this.testSubjects.find(partitionVisChartSelector); if (pieSlice) { const pieSize = await pie.getSize(); @@ -101,6 +101,17 @@ export class PieChartService extends FtrService { return await pieSlice.getAttribute('style'); } + async getAllPieSlicesColors() { + const slicesColors = []; + const slices = + (await this.visChart.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0] + ?.partitions ?? []; + for (const slice of slices) { + slicesColors.push(slice.color); + } + return slicesColors; + } + async getAllPieSliceColor(name: string) { this.log.debug(`VisualizePage.getAllPieSliceColor(${name})`); if (await this.visChart.isNewLibraryChart(partitionVisChartSelector)) { @@ -142,8 +153,8 @@ export class PieChartService extends FtrService { await this.inspector.expectTableData(expectedTableData); } - async getPieChartLabels() { - if (await this.visChart.isNewLibraryChart(partitionVisChartSelector)) { + async getPieChartLabels(isNewLibrary: boolean = true) { + if (isNewLibrary) { const slices = (await this.visChart.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0] ?.partitions ?? []; @@ -167,9 +178,9 @@ export class PieChartService extends FtrService { ); } - async getPieSliceCount() { + async getPieSliceCount(isNewLibrary: boolean = true) { this.log.debug('PieChart.getPieSliceCount'); - if (await this.visChart.isNewLibraryChart(partitionVisChartSelector)) { + if (isNewLibrary) { const slices = (await this.visChart.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0] ?.partitions ?? []; @@ -179,6 +190,24 @@ export class PieChartService extends FtrService { return slices.length; } + async getSliceCountForAllPies() { + let pieSlices = 0; + const charts = + (await this.visChart.getAllESChartsDebugDataByTestSubj(partitionVisChartSelector)) ?? []; + for (const chart of charts) { + const visContainer = await chart.findByCssSelector('.echChartStatus'); + const debugDataString: string | undefined = await visContainer.getAttribute( + 'data-ech-debug-state' + ); + if (debugDataString) { + const parsedData = JSON.parse(debugDataString); + const partition = parsedData?.partition?.[0] ?? []; + pieSlices += partition.partitions.length; + } + } + return pieSlices; + } + async expectPieSliceCountEsCharts(expectedCount: number) { const slices = (await this.visChart.getEsChartDebugState(partitionVisChartSelector))?.partition?.[0] @@ -186,18 +215,30 @@ export class PieChartService extends FtrService { expect(slices.length).to.be(expectedCount); } - async expectPieSliceCount(expectedCount: number) { + async expectPieSliceCount(expectedCount: number, isNewLibrary: boolean = true) { this.log.debug(`PieChart.expectPieSliceCount(${expectedCount})`); await this.retry.try(async () => { - const slicesCount = await this.getPieSliceCount(); + const slicesCount = await this.getPieSliceCount(isNewLibrary); + expect(slicesCount).to.be(expectedCount); + }); + } + + async expectSliceCountForAllPies(expectedCount: number) { + await this.retry.try(async () => { + const slicesCount = await this.getSliceCountForAllPies(); expect(slicesCount).to.be(expectedCount); }); } - async expectPieChartLabels(expectedLabels: string[]) { + async expectEmptyPieChart() { + const noResult = await this.testSubjects.exists('partitionVisEmptyValues'); + expect(noResult).to.be(true); + } + + async expectPieChartLabels(expectedLabels: string[], isNewLibrary: boolean = true) { this.log.debug(`PieChart.expectPieChartLabels(${expectedLabels.join(',')})`); await this.retry.try(async () => { - const pieData = await this.getPieChartLabels(); + const pieData = await this.getPieChartLabels(isNewLibrary); expect(pieData.sort()).to.eql(expectedLabels); }); } diff --git a/test/package/roles/install_kibana_docker/tasks/main.yml b/test/package/roles/install_kibana_docker/tasks/main.yml index 2b0b70de30b6c02..01dcf9f00bcce5a 100644 --- a/test/package/roles/install_kibana_docker/tasks/main.yml +++ b/test/package/roles/install_kibana_docker/tasks/main.yml @@ -24,3 +24,4 @@ ELASTICSEARCH_HOSTS: http://192.168.56.1:9200 ELASTICSEARCH_USERNAME: '{{ elasticsearch_username }}' ELASTICSEARCH_PASSWORD: '{{ elasticsearch_password }}' + XPACK_REPORTING_CAPTURE_BROWSER_CHROMIUM_DISABLESANDBOX: 'true' diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index 2d87c0575845fba..d295be040db7a9e 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -139,7 +139,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'newsfeed.fetchInterval (duration)', 'newsfeed.mainInterval (duration)', 'newsfeed.service.pathTemplate (string)', - 'newsfeed.service.urlRoot (any)', + 'newsfeed.service.urlRoot (string)', 'telemetry.allowChangingOptInStatus (boolean)', 'telemetry.banner (boolean)', 'telemetry.enabled (boolean)', diff --git a/tsconfig.base.json b/tsconfig.base.json index 78023a603276a0b..daf7bf78903c183 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -277,6 +277,8 @@ "@kbn/ui-actions-enhanced-examples-plugin/*": ["x-pack/examples/ui_actions_enhanced_examples/*"], "@kbn/actions-plugin": ["x-pack/plugins/actions"], "@kbn/actions-plugin/*": ["x-pack/plugins/actions/*"], + "@kbn/aiops-plugin": ["x-pack/plugins/aiops"], + "@kbn/aiops-plugin/*": ["x-pack/plugins/aiops/*"], "@kbn/alerting-plugin": ["x-pack/plugins/alerting"], "@kbn/alerting-plugin/*": ["x-pack/plugins/alerting/*"], "@kbn/apm-plugin": ["x-pack/plugins/apm"], diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index b1464f5cfbe2e76..738c5242813beb0 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -37,6 +37,7 @@ "xpack.logstash": ["plugins/logstash"], "xpack.main": "legacy/plugins/xpack_main", "xpack.maps": ["plugins/maps"], + "xpack.aiops": ["plugins/aiops"], "xpack.ml": ["plugins/ml"], "xpack.monitoring": ["plugins/monitoring"], "xpack.osquery": ["plugins/osquery"], diff --git a/x-pack/build_chromium/build.py b/x-pack/build_chromium/build.py index f4338ab2c6db131..fd5c1d3f16a656c 100644 --- a/x-pack/build_chromium/build.py +++ b/x-pack/build_chromium/build.py @@ -20,6 +20,8 @@ src_path = path.abspath(path.join(os.curdir, 'chromium', 'src')) build_path = path.abspath(path.join(src_path, '..', '..')) +en_us_locale_pak_file_name = 'en-US.pak' +en_us_locale_file_path = path.abspath(en_us_locale_pak_file_name) build_chromium_path = path.abspath(path.dirname(__file__)) argsgn_file = path.join(build_chromium_path, platform.system().lower(), 'args.gn') @@ -35,6 +37,9 @@ if arch_name != 'x64' and arch_name != 'arm64': raise Exception('Unexpected architecture: ' + arch_name + '. `x64` and `arm64` are supported.') +print('Fetching locale files') +runcmd('gsutil cp gs://headless_shell_staging/en-US.pak .') + print('Building Chromium ' + source_version + ' for ' + arch_name + ' from ' + src_path) print('src path: ' + src_path) print('depot_tools path: ' + path.join(build_path, 'depot_tools')) @@ -104,17 +109,13 @@ print('Creating ' + path.join(src_path, zip_filename)) archive = zipfile.ZipFile(zip_filename, mode='w', compression=zipfile.ZIP_DEFLATED) -def archive_file(name): - """A little helper function to write individual files to the zip file""" - from_path = path.join('out/headless', name) - to_path = path.join('headless_shell-' + platform.system().lower() + '_' + arch_name, name) - archive.write(from_path, to_path) - return to_path +path_prefix = 'headless_shell-' + platform.system().lower() + '_' + arch_name # Add dependencies that must be bundled with the Chromium executable. -archive_file('headless_shell') -archive_file(path.join('swiftshader', 'libEGL.so')) -archive_file(path.join('swiftshader', 'libGLESv2.so')) +archive.write('out/headless/headless_shell', path.join(path_prefix, 'headless_shell')) +archive.write('out/headless/libEGL.so', path.join(path_prefix, 'libEGL.so')) +archive.write('out/headless/libGLESv2.so', path.join(path_prefix, 'libGLESv2.so')) +archive.write(en_us_locale_file_path, path.join(path_prefix, 'locales', en_us_locale_pak_file_name)) archive.close() diff --git a/x-pack/plugins/actions/server/actions_client.mock.ts b/x-pack/plugins/actions/server/actions_client.mock.ts index 419babe97c0f472..246c8fa35fc15e4 100644 --- a/x-pack/plugins/actions/server/actions_client.mock.ts +++ b/x-pack/plugins/actions/server/actions_client.mock.ts @@ -19,6 +19,7 @@ const createActionsClientMock = () => { update: jest.fn(), getAll: jest.fn(), getBulk: jest.fn(), + getOAuthAccessToken: jest.fn(), execute: jest.fn(), enqueueExecution: jest.fn(), ephemeralEnqueuedExecution: jest.fn(), diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index afee13b8c9bcaba..787b4e450a9e06c 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -18,11 +18,14 @@ import { actionsConfigMock } from './actions_config.mock'; import { getActionsConfigurationUtilities } from './actions_config'; import { licenseStateMock } from './lib/license_state.mock'; import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; -import { httpServerMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { + httpServerMock, + loggingSystemMock, + elasticsearchServiceMock, + savedObjectsClientMock, +} from '@kbn/core/server/mocks'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { usageCountersServiceMock } from '@kbn/usage-collection-plugin/server/usage_counters/usage_counters_service.mock'; - -import { elasticsearchServiceMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; import { actionExecutorMock } from './lib/action_executor.mock'; import uuid from 'uuid'; import { ActionsAuthorization } from './authorization/actions_authorization'; @@ -37,6 +40,9 @@ import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/s import { Logger } from '@kbn/core/server'; import { connectorTokenClientMock } from './builtin_action_types/lib/connector_token_client.mock'; import { inMemoryMetricsMock } from './monitoring/in_memory_metrics.mock'; +import { getOAuthJwtAccessToken } from './builtin_action_types/lib/get_oauth_jwt_access_token'; +import { getOAuthClientCredentialsAccessToken } from './builtin_action_types/lib/get_oauth_client_credentials_access_token'; +import { OAuthParams } from './routes/get_oauth_access_token'; jest.mock('@kbn/core/server/saved_objects/service/lib/utils', () => ({ SavedObjectsUtils: { @@ -60,6 +66,13 @@ jest.mock('./authorization/get_authorization_mode_by_source', () => { }; }); +jest.mock('./builtin_action_types/lib/get_oauth_jwt_access_token', () => ({ + getOAuthJwtAccessToken: jest.fn(), +})); +jest.mock('./builtin_action_types/lib/get_oauth_client_credentials_access_token', () => ({ + getOAuthClientCredentialsAccessToken: jest.fn(), +})); + const defaultKibanaIndex = '.kibana'; const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); @@ -73,6 +86,7 @@ const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); const logger = loggingSystemMock.create().get() as jest.Mocked; const mockTaskManager = taskManagerMock.createSetup(); +const configurationUtilities = actionsConfigMock.create(); let actionsClient: ActionsClient; let mockedLicenseState: jest.Mocked; @@ -115,6 +129,10 @@ beforeEach(() => { usageCounter: mockUsageCounter, connectorTokenClient, }); + (getOAuthJwtAccessToken as jest.Mock).mockResolvedValue(`Bearer jwttokentokentoken`); + (getOAuthClientCredentialsAccessToken as jest.Mock).mockResolvedValue( + `Bearer clienttokentokentoken` + ); }); describe('create()', () => { @@ -1274,6 +1292,292 @@ describe('getBulk()', () => { }); }); +describe('getOAuthAccessToken()', () => { + function getOAuthAccessToken( + requestBody: OAuthParams + ): ReturnType { + actionsClient = new ActionsClient({ + actionTypeRegistry, + unsecuredSavedObjectsClient, + scopedClusterClient, + defaultKibanaIndex, + actionExecutor, + executionEnqueuer, + ephemeralExecutionEnqueuer, + request, + authorization: authorization as unknown as ActionsAuthorization, + preconfiguredActions: [ + { + id: 'testPreconfigured', + actionTypeId: '.slack', + secrets: {}, + isPreconfigured: true, + isDeprecated: false, + name: 'test', + config: { + foo: 'bar', + }, + }, + ], + connectorTokenClient: connectorTokenClientMock.create(), + }); + return actionsClient.getOAuthAccessToken(requestBody, logger, configurationUtilities); + } + + describe('authorization', () => { + test('ensures user is authorised to get the type of action', async () => { + await getOAuthAccessToken({ + type: 'jwt', + options: { + tokenUrl: 'https://testurl.service-now.com/oauth_token.do', + config: { + clientId: 'abc', + jwtKeyId: 'def', + userIdentifierValue: 'userA', + }, + secrets: { + clientSecret: 'iamasecret', + privateKey: 'xyz', + }, + }, + }); + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update'); + }); + + test('throws when user is not authorised to create the type of action', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error(`Unauthorized to update actions`)); + + await expect( + getOAuthAccessToken({ + type: 'jwt', + options: { + tokenUrl: 'https://testurl.service-now.com/oauth_token.do', + config: { + clientId: 'abc', + jwtKeyId: 'def', + userIdentifierValue: 'userA', + }, + secrets: { + clientSecret: 'iamasecret', + privateKey: 'xyz', + }, + }, + }) + ).rejects.toMatchInlineSnapshot(`[Error: Unauthorized to update actions]`); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update'); + }); + }); + + test('throws when tokenUrl is not using http or https', async () => { + await expect( + getOAuthAccessToken({ + type: 'jwt', + options: { + tokenUrl: 'ftp://testurl.service-now.com/oauth_token.do', + config: { + clientId: 'abc', + jwtKeyId: 'def', + userIdentifierValue: 'userA', + }, + secrets: { + clientSecret: 'iamasecret', + privateKey: 'xyz', + }, + }, + }) + ).rejects.toMatchInlineSnapshot(`[Error: Token URL must use http or https]`); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update'); + }); + + test('throws when tokenUrl does not contain hostname', async () => { + await expect( + getOAuthAccessToken({ + type: 'jwt', + options: { + tokenUrl: '/path/to/myfile', + config: { + clientId: 'abc', + jwtKeyId: 'def', + userIdentifierValue: 'userA', + }, + secrets: { + clientSecret: 'iamasecret', + privateKey: 'xyz', + }, + }, + }) + ).rejects.toMatchInlineSnapshot(`[Error: Token URL must contain hostname]`); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update'); + }); + + test('throws when tokenUrl is not in allowed hosts', async () => { + configurationUtilities.ensureUriAllowed.mockImplementationOnce(() => { + throw new Error('URI not allowed'); + }); + + await expect( + getOAuthAccessToken({ + type: 'jwt', + options: { + tokenUrl: 'https://testurl.service-now.com/oauth_token.do', + config: { + clientId: 'abc', + jwtKeyId: 'def', + userIdentifierValue: 'userA', + }, + secrets: { + clientSecret: 'iamasecret', + privateKey: 'xyz', + }, + }, + }) + ).rejects.toMatchInlineSnapshot(`[Error: URI not allowed]`); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update'); + expect(configurationUtilities.ensureUriAllowed).toHaveBeenCalledWith( + `https://testurl.service-now.com/oauth_token.do` + ); + }); + + test('calls getOAuthJwtAccessToken when type="jwt"', async () => { + const result = await getOAuthAccessToken({ + type: 'jwt', + options: { + tokenUrl: 'https://testurl.service-now.com/oauth_token.do', + config: { + clientId: 'abc', + jwtKeyId: 'def', + userIdentifierValue: 'userA', + }, + secrets: { + clientSecret: 'iamasecret', + privateKey: 'xyz', + }, + }, + }); + expect(result).toEqual({ + accessToken: 'Bearer jwttokentokentoken', + }); + expect(getOAuthJwtAccessToken as jest.Mock).toHaveBeenCalledWith({ + logger, + configurationUtilities, + credentials: { + config: { + clientId: 'abc', + jwtKeyId: 'def', + userIdentifierValue: 'userA', + }, + secrets: { + clientSecret: 'iamasecret', + privateKey: 'xyz', + }, + }, + tokenUrl: 'https://testurl.service-now.com/oauth_token.do', + }); + expect(getOAuthClientCredentialsAccessToken).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + `Successfully retrieved access token using JWT OAuth with tokenUrl https://testurl.service-now.com/oauth_token.do and config {\"clientId\":\"abc\",\"jwtKeyId\":\"def\",\"userIdentifierValue\":\"userA\"}` + ); + }); + + test('calls getOAuthClientCredentialsAccessToken when type="client"', async () => { + const result = await getOAuthAccessToken({ + type: 'client', + options: { + tokenUrl: 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + scope: 'https://graph.microsoft.com/.default', + config: { + clientId: 'abc', + tenantId: 'def', + }, + secrets: { + clientSecret: 'iamasecret', + }, + }, + }); + expect(result).toEqual({ + accessToken: 'Bearer clienttokentokentoken', + }); + expect(getOAuthClientCredentialsAccessToken as jest.Mock).toHaveBeenCalledWith({ + logger, + configurationUtilities, + credentials: { + config: { + clientId: 'abc', + tenantId: 'def', + }, + secrets: { + clientSecret: 'iamasecret', + }, + }, + tokenUrl: 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + oAuthScope: 'https://graph.microsoft.com/.default', + }); + expect(getOAuthJwtAccessToken).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + `Successfully retrieved access token using Client Credentials OAuth with tokenUrl https://login.microsoftonline.com/98765/oauth2/v2.0/token, scope https://graph.microsoft.com/.default and config {\"clientId\":\"abc\",\"tenantId\":\"def\"}` + ); + }); + + test('throws when getOAuthJwtAccessToken throws error', async () => { + (getOAuthJwtAccessToken as jest.Mock).mockRejectedValue(new Error(`Something went wrong!`)); + + await expect( + getOAuthAccessToken({ + type: 'jwt', + options: { + tokenUrl: 'https://testurl.service-now.com/oauth_token.do', + config: { + clientId: 'abc', + jwtKeyId: 'def', + userIdentifierValue: 'userA', + }, + secrets: { + clientSecret: 'iamasecret', + privateKey: 'xyz', + }, + }, + }) + ).rejects.toMatchInlineSnapshot(`[Error: Failed to retrieve access token]`); + + expect(getOAuthJwtAccessToken as jest.Mock).toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + `Failed to retrieve access token using JWT OAuth with tokenUrl https://testurl.service-now.com/oauth_token.do and config {\"clientId\":\"abc\",\"jwtKeyId\":\"def\",\"userIdentifierValue\":\"userA\"} - Something went wrong!` + ); + }); + + test('throws when getOAuthClientCredentialsAccessToken throws error', async () => { + (getOAuthClientCredentialsAccessToken as jest.Mock).mockRejectedValue( + new Error(`Something went wrong!`) + ); + + await expect( + getOAuthAccessToken({ + type: 'client', + options: { + tokenUrl: 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + scope: 'https://graph.microsoft.com/.default', + config: { + clientId: 'abc', + tenantId: 'def', + }, + secrets: { + clientSecret: 'iamasecret', + }, + }, + }) + ).rejects.toMatchInlineSnapshot(`[Error: Failed to retrieve access token]`); + + expect(getOAuthClientCredentialsAccessToken as jest.Mock).toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + `Failed to retrieved access token using Client Credentials OAuth with tokenUrl https://login.microsoftonline.com/98765/oauth2/v2.0/token, scope https://graph.microsoft.com/.default and config {\"clientId\":\"abc\",\"tenantId\":\"def\"} - Something went wrong!` + ); + }); +}); + describe('delete()', () => { describe('authorization', () => { test('ensures user is authorised to delete actions', async () => { diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts index dacf6de36bd37dc..89156bb56b51a83 100644 --- a/x-pack/plugins/actions/server/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client.ts @@ -6,6 +6,7 @@ */ import Boom from '@hapi/boom'; +import url from 'url'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { UsageCounter } from '@kbn/usage-collection-plugin/server'; @@ -18,6 +19,7 @@ import { SavedObject, KibanaRequest, SavedObjectsUtils, + Logger, } from '@kbn/core/server'; import { AuditLogger } from '@kbn/security-plugin/server'; import { RunNowResult } from '@kbn/task-manager-plugin/server'; @@ -46,6 +48,22 @@ import { import { connectorAuditEvent, ConnectorAuditAction } from './lib/audit_events'; import { trackLegacyRBACExemption } from './lib/track_legacy_rbac_exemption'; import { isConnectorDeprecated } from './lib/is_conector_deprecated'; +import { ActionsConfigurationUtilities } from './actions_config'; +import { + OAuthClientCredentialsParams, + OAuthJwtParams, + OAuthParams, +} from './routes/get_oauth_access_token'; +import { + getOAuthJwtAccessToken, + GetOAuthJwtConfig, + GetOAuthJwtSecrets, +} from './builtin_action_types/lib/get_oauth_jwt_access_token'; +import { + getOAuthClientCredentialsAccessToken, + GetOAuthClientCredentialsConfig, + GetOAuthClientCredentialsSecrets, +} from './builtin_action_types/lib/get_oauth_client_credentials_access_token'; // We are assuming there won't be many actions. This is why we will load // all the actions in advance and assume the total count to not go over 10000. @@ -448,6 +466,98 @@ export class ActionsClient { return actionResults; } + public async getOAuthAccessToken( + { type, options }: OAuthParams, + logger: Logger, + configurationUtilities: ActionsConfigurationUtilities + ) { + // Verify that user has edit access + await this.authorization.ensureAuthorized('update'); + + // Verify that token url is allowed by allowed hosts config + try { + configurationUtilities.ensureUriAllowed(options.tokenUrl); + } catch (err) { + throw Boom.badRequest(err.message); + } + + // Verify that token url contains a hostname and uses https + const parsedUrl = url.parse( + options.tokenUrl, + false /* parseQueryString */, + true /* slashesDenoteHost */ + ); + + if (!parsedUrl.hostname) { + throw Boom.badRequest(`Token URL must contain hostname`); + } + + if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') { + throw Boom.badRequest(`Token URL must use http or https`); + } + + let accessToken: string | null = null; + if (type === 'jwt') { + const tokenOpts = options as OAuthJwtParams; + + try { + accessToken = await getOAuthJwtAccessToken({ + logger, + configurationUtilities, + credentials: { + config: tokenOpts.config as GetOAuthJwtConfig, + secrets: tokenOpts.secrets as GetOAuthJwtSecrets, + }, + tokenUrl: tokenOpts.tokenUrl, + }); + + logger.debug( + `Successfully retrieved access token using JWT OAuth with tokenUrl ${ + tokenOpts.tokenUrl + } and config ${JSON.stringify(tokenOpts.config)}` + ); + } catch (err) { + logger.debug( + `Failed to retrieve access token using JWT OAuth with tokenUrl ${ + tokenOpts.tokenUrl + } and config ${JSON.stringify(tokenOpts.config)} - ${err.message}` + ); + throw Boom.badRequest(`Failed to retrieve access token`); + } + } else if (type === 'client') { + const tokenOpts = options as OAuthClientCredentialsParams; + try { + accessToken = await getOAuthClientCredentialsAccessToken({ + logger, + configurationUtilities, + credentials: { + config: tokenOpts.config as GetOAuthClientCredentialsConfig, + secrets: tokenOpts.secrets as GetOAuthClientCredentialsSecrets, + }, + tokenUrl: tokenOpts.tokenUrl, + oAuthScope: tokenOpts.scope, + }); + + logger.debug( + `Successfully retrieved access token using Client Credentials OAuth with tokenUrl ${ + tokenOpts.tokenUrl + }, scope ${tokenOpts.scope} and config ${JSON.stringify(tokenOpts.config)}` + ); + } catch (err) { + logger.debug( + `Failed to retrieved access token using Client Credentials OAuth with tokenUrl ${ + tokenOpts.tokenUrl + }, scope ${tokenOpts.scope} and config ${JSON.stringify(tokenOpts.config)} - ${ + err.message + }` + ); + throw Boom.badRequest(`Failed to retrieve access token`); + } + } + + return { accessToken }; + } + /** * Delete action */ diff --git a/x-pack/plugins/actions/server/actions_config.test.ts b/x-pack/plugins/actions/server/actions_config.test.ts index 470e6ce8cdc8e75..a6b68d907cb44c8 100644 --- a/x-pack/plugins/actions/server/actions_config.test.ts +++ b/x-pack/plugins/actions/server/actions_config.test.ts @@ -129,6 +129,17 @@ describe('isUriAllowed', () => { ).toEqual(true); }); + test('returns true for network path references', () => { + const config: ActionsConfig = { + ...defaultActionsConfig, + allowedHosts: ['my-domain.com'], + enabledActionTypes: [], + }; + expect(getActionsConfigurationUtilities(config).isUriAllowed('//my-domain.com/foo')).toEqual( + true + ); + }); + test('throws when the hostname in the requested uri is not in the allowedHosts', () => { const config: ActionsConfig = defaultActionsConfig; expect( diff --git a/x-pack/plugins/actions/server/actions_config.ts b/x-pack/plugins/actions/server/actions_config.ts index 35e08bb5cfe6692..49f1d1fd5445e44 100644 --- a/x-pack/plugins/actions/server/actions_config.ts +++ b/x-pack/plugins/actions/server/actions_config.ts @@ -76,7 +76,7 @@ function isAllowed({ allowedHosts }: ActionsConfig, hostname: string | null): bo function isHostnameAllowedInUri(config: ActionsConfig, uri: string): boolean { return pipe( - tryCatch(() => url.parse(uri)), + tryCatch(() => url.parse(uri, false /* parseQueryString */, true /* slashesDenoteHost */)), map((parsedUrl) => parsedUrl.hostname), mapNullable((hostname) => isAllowed(config, hostname)), getOrElse(() => false) diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts index a7a9d135f2f2e96..c367c9ea44919bb 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/axios_utils.ts @@ -18,6 +18,7 @@ export const request = async ({ method = 'get', data, configurationUtilities, + headers, ...rest }: { axios: AxiosInstance; @@ -37,6 +38,8 @@ export const request = async ({ return await axios(url, { ...rest, method, + // Axios doesn't support `null` value for `headers` property. + headers: headers ?? undefined, data: data ?? {}, // use httpAgent and httpsAgent and set axios proxy: false, to be able to handle fail on invalid certs httpAgent, diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.test.ts index 54765b9e01b8f81..dfa307ca3cd91b8 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import sinon from 'sinon'; import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; import { ConnectorTokenClient } from './connector_token_client'; @@ -23,7 +24,13 @@ const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); let connectorTokenClient: ConnectorTokenClient; +let clock: sinon.SinonFakeTimers; + +beforeAll(() => { + clock = sinon.useFakeTimers(new Date('2021-01-01T12:00:00.000Z')); +}); beforeEach(() => { + clock.reset(); jest.resetAllMocks(); connectorTokenClient = new ConnectorTokenClient({ unsecuredSavedObjectsClient, @@ -31,6 +38,7 @@ beforeEach(() => { logger, }); }); +afterAll(() => clock.restore()); describe('create()', () => { test('creates connector_token with all given properties', async () => { @@ -131,7 +139,7 @@ describe('get()', () => { expect(result).toEqual({ connectorToken: null, hasErrors: false }); }); - test('return null and log the error if unsecuredSavedObjectsClient thows an error', async () => { + test('return null and log the error if unsecuredSavedObjectsClient throws an error', async () => { unsecuredSavedObjectsClient.find.mockRejectedValueOnce(new Error('Fail')); const result = await connectorTokenClient.get({ @@ -145,7 +153,7 @@ describe('get()', () => { expect(result).toEqual({ connectorToken: null, hasErrors: true }); }); - test('return null and log the error if encryptedSavedObjectsClient decrypt method thows an error', async () => { + test('return null and log the error if encryptedSavedObjectsClient decrypt method throws an error', async () => { const expectedResult = { total: 1, per_page: 10, @@ -178,6 +186,47 @@ describe('get()', () => { ]); expect(result).toEqual({ connectorToken: null, hasErrors: true }); }); + + test('return null and log the error if expiresAt is NaN', async () => { + const expectedResult = { + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + createdAt: new Date().toISOString(), + expiresAt: 'yo', + }, + score: 1, + references: [], + }, + ], + }; + unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + references: [], + attributes: { + token: 'testtokenvalue', + }, + }); + + const result = await connectorTokenClient.get({ + connectorId: '123', + tokenType: 'access_token', + }); + + expect(logger.error.mock.calls[0]).toMatchObject([ + `Failed to get connector_token for connectorId "123" and tokenType: "access_token". Error: expiresAt is not a valid Date "yo"`, + ]); + expect(result).toEqual({ connectorToken: null, hasErrors: true }); + }); }); describe('update()', () => { @@ -375,12 +424,60 @@ describe('updateOrReplace()', () => { connectorId: '1', token: null, newToken: 'newToken', + tokenRequestDate: undefined as unknown as number, expiresInSec: 1000, deleteExisting: false, }); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); - expect((unsecuredSavedObjectsClient.create.mock.calls[0][1] as ConnectorToken).token).toBe( - 'newToken' + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'connector_token', + { + connectorId: '1', + createdAt: '2021-01-01T12:00:00.000Z', + expiresAt: '2021-01-01T12:16:40.000Z', + token: 'newToken', + tokenType: 'access_token', + updatedAt: '2021-01-01T12:00:00.000Z', + }, + { id: 'mock-saved-object-id' } + ); + + expect(unsecuredSavedObjectsClient.find).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.delete).not.toHaveBeenCalled(); + }); + + test('uses tokenRequestDate to determine expire time if provided', async () => { + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAt: new Date('2021-01-01T08:00:00.000Z').toISOString(), + }, + references: [], + }); + await connectorTokenClient.updateOrReplace({ + connectorId: '1', + token: null, + newToken: 'newToken', + tokenRequestDate: new Date('2021-03-03T00:00:00.000Z').getTime(), + expiresInSec: 1000, + deleteExisting: false, + }); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'connector_token', + { + connectorId: '1', + createdAt: '2021-01-01T12:00:00.000Z', + expiresAt: '2021-03-03T00:16:40.000Z', + token: 'newToken', + tokenType: 'access_token', + updatedAt: '2021-01-01T12:00:00.000Z', + }, + { id: 'mock-saved-object-id' } ); expect(unsecuredSavedObjectsClient.find).not.toHaveBeenCalled(); @@ -434,6 +531,7 @@ describe('updateOrReplace()', () => { connectorId: '1', token: null, newToken: 'newToken', + tokenRequestDate: Date.now(), expiresInSec: 1000, deleteExisting: true, }); @@ -483,6 +581,7 @@ describe('updateOrReplace()', () => { expiresAt: new Date().toISOString(), }, newToken: 'newToken', + tokenRequestDate: Date.now(), expiresInSec: 1000, deleteExisting: true, }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.ts index 6ce91fad9454617..df1615d5033296b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.ts @@ -38,6 +38,7 @@ interface UpdateOrReplaceOptions { token: ConnectorToken | null; newToken: string; expiresInSec: number; + tokenRequestDate: number; deleteExisting: boolean; } export class ConnectorTokenClient { @@ -195,6 +196,7 @@ export class ConnectorTokenClient { return { hasErrors: false, connectorToken: null }; } + let accessToken: string; try { const { attributes: { token }, @@ -203,14 +205,7 @@ export class ConnectorTokenClient { connectorTokensResult[0].id ); - return { - hasErrors: false, - connectorToken: { - id: connectorTokensResult[0].id, - ...connectorTokensResult[0].attributes, - token, - }, - }; + accessToken = token; } catch (err) { this.logger.error( `Failed to decrypt connector_token for connectorId "${connectorId}" and tokenType: "${ @@ -219,6 +214,24 @@ export class ConnectorTokenClient { ); return { hasErrors: true, connectorToken: null }; } + + if (isNaN(Date.parse(connectorTokensResult[0].attributes.expiresAt))) { + this.logger.error( + `Failed to get connector_token for connectorId "${connectorId}" and tokenType: "${ + tokenType ?? 'access_token' + }". Error: expiresAt is not a valid Date "${connectorTokensResult[0].attributes.expiresAt}"` + ); + return { hasErrors: true, connectorToken: null }; + } + + return { + hasErrors: false, + connectorToken: { + id: connectorTokensResult[0].id, + ...connectorTokensResult[0].attributes, + token: accessToken, + }, + }; } /** @@ -258,9 +271,11 @@ export class ConnectorTokenClient { token, newToken, expiresInSec, + tokenRequestDate, deleteExisting, }: UpdateOrReplaceOptions) { expiresInSec = expiresInSec ?? 3600; + tokenRequestDate = tokenRequestDate ?? Date.now(); if (token === null) { if (deleteExisting) { await this.deleteConnectorTokens({ @@ -272,14 +287,14 @@ export class ConnectorTokenClient { await this.create({ connectorId, token: newToken, - expiresAtMillis: new Date(Date.now() + expiresInSec * 1000).toISOString(), + expiresAtMillis: new Date(tokenRequestDate + expiresInSec * 1000).toISOString(), tokenType: 'access_token', }); } else { await this.update({ id: token.id!.toString(), token: newToken, - expiresAtMillis: new Date(Date.now() + expiresInSec * 1000).toISOString(), + expiresAtMillis: new Date(tokenRequestDate + expiresInSec * 1000).toISOString(), tokenType: 'access_token', }); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.ts index b33a2d17ed9d84d..9dde4790c152d31 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.ts @@ -20,8 +20,7 @@ export function createJWTAssertion( logger: Logger, privateKey: string, privateKeyPassword: string | null, - reservedClaims: JWTClaims, - customClaims?: Record + reservedClaims: JWTClaims ): string { const { subject, audience, issuer, expireInMilliseconds, keyId } = reservedClaims; const iat = Math.floor(Date.now() / 1000); @@ -34,7 +33,6 @@ export function createJWTAssertion( iss: issuer, // issuer claim identifies the principal that issued the JWT iat, // issued at claim identifies the time at which the JWT was issued exp: iat + (expireInMilliseconds ?? 3600), // expiration time claim identifies the expiration time on or after which the JWT MUST NOT be accepted for processing - ...(customClaims ?? {}), }; try { diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_client_credentials_access_token.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_client_credentials_access_token.test.ts new file mode 100644 index 000000000000000..c3464a11e557e62 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_client_credentials_access_token.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 { Logger } from '@kbn/core/server'; +import { asyncForEach } from '@kbn/std'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; +import { connectorTokenClientMock } from './connector_token_client.mock'; +import { getOAuthClientCredentialsAccessToken } from './get_oauth_client_credentials_access_token'; +import { requestOAuthClientCredentialsToken } from './request_oauth_client_credentials_token'; + +jest.mock('./request_oauth_client_credentials_token', () => ({ + requestOAuthClientCredentialsToken: jest.fn(), +})); + +const logger = loggingSystemMock.create().get() as jest.Mocked; +const configurationUtilities = actionsConfigMock.create(); +const connectorTokenClient = connectorTokenClientMock.create(); + +let clock: sinon.SinonFakeTimers; + +describe('getOAuthClientCredentialsAccessToken', () => { + beforeAll(() => { + clock = sinon.useFakeTimers(new Date('2021-01-01T12:00:00.000Z')); + }); + beforeEach(() => clock.reset()); + afterAll(() => clock.restore()); + + const getOAuthClientCredentialsAccessTokenOpts = { + connectorId: '123', + logger, + configurationUtilities, + credentials: { + config: { + clientId: 'clientId', + tenantId: 'tenantId', + }, + secrets: { + clientSecret: 'clientSecret', + }, + }, + oAuthScope: 'https://graph.microsoft.com/.default', + tokenUrl: 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + connectorTokenClient, + }; + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + test('uses stored access token if it exists', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { + id: '1', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt: new Date('2021-01-01T08:00:00.000Z').toISOString(), + expiresAt: new Date('2021-01-02T13:00:00.000Z').toISOString(), + }, + }); + const accessToken = await getOAuthClientCredentialsAccessToken( + getOAuthClientCredentialsAccessTokenOpts + ); + + expect(accessToken).toEqual('testtokenvalue'); + expect(requestOAuthClientCredentialsToken as jest.Mock).not.toHaveBeenCalled(); + }); + + test('creates new assertion if stored access token does not exist', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: null, + }); + (requestOAuthClientCredentialsToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + expiresIn: 1000, + }); + + const accessToken = await getOAuthClientCredentialsAccessToken( + getOAuthClientCredentialsAccessTokenOpts + ); + + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(requestOAuthClientCredentialsToken as jest.Mock).toHaveBeenCalledWith( + 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + logger, + { + scope: 'https://graph.microsoft.com/.default', + clientId: 'clientId', + clientSecret: 'clientSecret', + }, + + configurationUtilities + ); + expect(connectorTokenClient.updateOrReplace).toHaveBeenCalledWith({ + connectorId: '123', + token: null, + newToken: 'access_token brandnewaccesstoken', + tokenRequestDate: 1609502400000, + expiresInSec: 1000, + deleteExisting: false, + }); + }); + + test('creates new assertion if stored access token exists but is expired', async () => { + const createdAt = new Date('2021-01-01T08:00:00.000Z').toISOString(); + const expiresAt = new Date('2021-01-01T09:00:00.000Z').toISOString(); + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { + id: '1', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt, + expiresAt, + }, + }); + (requestOAuthClientCredentialsToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + expiresIn: 1000, + }); + + const accessToken = await getOAuthClientCredentialsAccessToken( + getOAuthClientCredentialsAccessTokenOpts + ); + + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(requestOAuthClientCredentialsToken as jest.Mock).toHaveBeenCalledWith( + 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + logger, + { + scope: 'https://graph.microsoft.com/.default', + clientId: 'clientId', + clientSecret: 'clientSecret', + }, + + configurationUtilities + ); + expect(connectorTokenClient.updateOrReplace).toHaveBeenCalledWith({ + connectorId: '123', + token: { + id: '1', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt, + expiresAt, + }, + newToken: 'access_token brandnewaccesstoken', + tokenRequestDate: 1609502400000, + expiresInSec: 1000, + deleteExisting: false, + }); + }); + + test('returns null and logs warning if any required fields are missing', async () => { + await asyncForEach(['clientId', 'tenantId'], async (configField: string) => { + const accessToken = await getOAuthClientCredentialsAccessToken({ + ...getOAuthClientCredentialsAccessTokenOpts, + credentials: { + config: { + ...getOAuthClientCredentialsAccessTokenOpts.credentials.config, + [configField]: null, + }, + secrets: getOAuthClientCredentialsAccessTokenOpts.credentials.secrets, + }, + }); + expect(accessToken).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + `Missing required fields for requesting OAuth Client Credentials access token` + ); + }); + + await asyncForEach(['clientSecret'], async (secretsField: string) => { + const accessToken = await getOAuthClientCredentialsAccessToken({ + ...getOAuthClientCredentialsAccessTokenOpts, + credentials: { + config: getOAuthClientCredentialsAccessTokenOpts.credentials.config, + secrets: { + ...getOAuthClientCredentialsAccessTokenOpts.credentials.secrets, + [secretsField]: null, + }, + }, + }); + expect(accessToken).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + `Missing required fields for requesting OAuth Client Credentials access token` + ); + }); + }); + + test('throws error if requestOAuthClientCredentialsToken throws error', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: null, + }); + (requestOAuthClientCredentialsToken as jest.Mock).mockRejectedValueOnce( + new Error('requestOAuthClientCredentialsToken error!!') + ); + + await expect( + getOAuthClientCredentialsAccessToken(getOAuthClientCredentialsAccessTokenOpts) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"requestOAuthClientCredentialsToken error!!"`); + }); + + test('logs warning if connectorTokenClient.updateOrReplace throws error', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: null, + }); + (requestOAuthClientCredentialsToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + tokenRequestDate: 1609502400000, + expiresIn: 1000, + }); + connectorTokenClient.updateOrReplace.mockRejectedValueOnce(new Error('updateOrReplace error')); + + const accessToken = await getOAuthClientCredentialsAccessToken( + getOAuthClientCredentialsAccessTokenOpts + ); + + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(logger.warn).toHaveBeenCalledWith( + `Not able to update connector token for connectorId: 123 due to error: updateOrReplace error` + ); + }); + + test('gets access token if connectorId is not provided', async () => { + (requestOAuthClientCredentialsToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + expiresIn: 1000, + }); + + const accessToken = await getOAuthClientCredentialsAccessToken({ + logger, + configurationUtilities, + credentials: { + config: { + clientId: 'clientId', + tenantId: 'tenantId', + }, + secrets: { + clientSecret: 'clientSecret', + }, + }, + oAuthScope: 'https://graph.microsoft.com/.default', + tokenUrl: 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + connectorTokenClient, + }); + + expect(connectorTokenClient.get).not.toHaveBeenCalled(); + expect(connectorTokenClient.updateOrReplace).not.toHaveBeenCalled(); + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(requestOAuthClientCredentialsToken as jest.Mock).toHaveBeenCalledWith( + 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + logger, + { + scope: 'https://graph.microsoft.com/.default', + clientId: 'clientId', + clientSecret: 'clientSecret', + }, + + configurationUtilities + ); + }); + + test('gets access token if connectorTokenClient is not provided', async () => { + (requestOAuthClientCredentialsToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + tokenRequestDate: 1609502400000, + expiresIn: 1000, + }); + + const accessToken = await getOAuthClientCredentialsAccessToken({ + connectorId: '123', + logger, + configurationUtilities, + credentials: { + config: { + clientId: 'clientId', + tenantId: 'tenantId', + }, + secrets: { + clientSecret: 'clientSecret', + }, + }, + oAuthScope: 'https://graph.microsoft.com/.default', + tokenUrl: 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + }); + + expect(connectorTokenClient.get).not.toHaveBeenCalled(); + expect(connectorTokenClient.updateOrReplace).not.toHaveBeenCalled(); + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(requestOAuthClientCredentialsToken as jest.Mock).toHaveBeenCalledWith( + 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + logger, + { + scope: 'https://graph.microsoft.com/.default', + clientId: 'clientId', + clientSecret: 'clientSecret', + }, + + configurationUtilities + ); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_client_credentials_access_token.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_client_credentials_access_token.ts new file mode 100644 index 000000000000000..1cce245a154c2c2 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_client_credentials_access_token.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { ConnectorToken, ConnectorTokenClientContract } from '../../types'; +import { requestOAuthClientCredentialsToken } from './request_oauth_client_credentials_token'; + +export interface GetOAuthClientCredentialsConfig { + clientId: string; + tenantId: string; +} + +export interface GetOAuthClientCredentialsSecrets { + clientSecret: string; +} + +interface GetOAuthClientCredentialsAccessTokenOpts { + connectorId?: string; + tokenUrl: string; + oAuthScope: string; + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; + credentials: { + config: GetOAuthClientCredentialsConfig; + secrets: GetOAuthClientCredentialsSecrets; + }; + connectorTokenClient?: ConnectorTokenClientContract; +} + +export const getOAuthClientCredentialsAccessToken = async ({ + connectorId, + logger, + tokenUrl, + oAuthScope, + configurationUtilities, + credentials, + connectorTokenClient, +}: GetOAuthClientCredentialsAccessTokenOpts) => { + const { clientId, tenantId } = credentials.config; + const { clientSecret } = credentials.secrets; + + if (!clientId || !clientSecret || !tenantId) { + logger.warn(`Missing required fields for requesting OAuth Client Credentials access token`); + return null; + } + + let accessToken: string; + let connectorToken: ConnectorToken | null = null; + let hasErrors: boolean = false; + + if (connectorId && connectorTokenClient) { + // Check if there is a token stored for this connector + const { connectorToken: token, hasErrors: errors } = await connectorTokenClient.get({ + connectorId, + }); + connectorToken = token; + hasErrors = errors; + } + + if (connectorToken === null || Date.parse(connectorToken.expiresAt) <= Date.now()) { + // Save the time before requesting token so we can use it to calculate expiration + const requestTokenStart = Date.now(); + + // request access token with jwt assertion + const tokenResult = await requestOAuthClientCredentialsToken( + tokenUrl, + logger, + { + scope: oAuthScope, + clientId, + clientSecret, + }, + configurationUtilities + ); + accessToken = `${tokenResult.tokenType} ${tokenResult.accessToken}`; + + // try to update connector_token SO + if (connectorId && connectorTokenClient) { + try { + await connectorTokenClient.updateOrReplace({ + connectorId, + token: connectorToken, + newToken: accessToken, + tokenRequestDate: requestTokenStart, + expiresInSec: tokenResult.expiresIn, + deleteExisting: hasErrors, + }); + } catch (err) { + logger.warn( + `Not able to update connector token for connectorId: ${connectorId} due to error: ${err.message}` + ); + } + } + } else { + // use existing valid token + accessToken = connectorToken.token; + } + return accessToken; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_jwt_access_token.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_jwt_access_token.test.ts new file mode 100644 index 000000000000000..0fe837fc0581aa2 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_jwt_access_token.test.ts @@ -0,0 +1,361 @@ +/* + * 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 { Logger } from '@kbn/core/server'; +import { asyncForEach } from '@kbn/std'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { actionsConfigMock } from '../../actions_config.mock'; +import { connectorTokenClientMock } from './connector_token_client.mock'; +import { getOAuthJwtAccessToken } from './get_oauth_jwt_access_token'; +import { createJWTAssertion } from './create_jwt_assertion'; +import { requestOAuthJWTToken } from './request_oauth_jwt_token'; + +jest.mock('./create_jwt_assertion', () => ({ + createJWTAssertion: jest.fn(), +})); +jest.mock('./request_oauth_jwt_token', () => ({ + requestOAuthJWTToken: jest.fn(), +})); + +const logger = loggingSystemMock.create().get() as jest.Mocked; +const configurationUtilities = actionsConfigMock.create(); +const connectorTokenClient = connectorTokenClientMock.create(); + +let clock: sinon.SinonFakeTimers; + +describe('getOAuthJwtAccessToken', () => { + beforeAll(() => { + clock = sinon.useFakeTimers(new Date('2021-01-01T12:00:00.000Z')); + }); + beforeEach(() => clock.reset()); + afterAll(() => clock.restore()); + + const getOAuthJwtAccessTokenOpts = { + connectorId: '123', + logger, + configurationUtilities, + credentials: { + config: { + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'userIdentifierValue', + }, + secrets: { + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: 'privateKeyPassword', + }, + }, + tokenUrl: 'https://dev23432523.service-now.com/oauth_token.do', + connectorTokenClient, + }; + + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + test('uses stored access token if it exists', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { + id: '1', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt: new Date('2021-01-01T08:00:00.000Z').toISOString(), + expiresAt: new Date('2021-01-02T13:00:00.000Z').toISOString(), + }, + }); + const accessToken = await getOAuthJwtAccessToken(getOAuthJwtAccessTokenOpts); + + expect(accessToken).toEqual('testtokenvalue'); + expect(createJWTAssertion as jest.Mock).not.toHaveBeenCalled(); + expect(requestOAuthJWTToken as jest.Mock).not.toHaveBeenCalled(); + }); + + test('creates new assertion if stored access token does not exist', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: null, + }); + (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); + (requestOAuthJWTToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + expiresIn: 1000, + }); + + const accessToken = await getOAuthJwtAccessToken(getOAuthJwtAccessTokenOpts); + + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(createJWTAssertion as jest.Mock).toHaveBeenCalledWith( + logger, + 'privateKey', + 'privateKeyPassword', + { + audience: 'clientId', + issuer: 'clientId', + subject: 'userIdentifierValue', + keyId: 'jwtKeyId', + } + ); + expect(requestOAuthJWTToken as jest.Mock).toHaveBeenCalledWith( + 'https://dev23432523.service-now.com/oauth_token.do', + { clientId: 'clientId', clientSecret: 'clientSecret', assertion: 'newassertion' }, + logger, + configurationUtilities + ); + expect(connectorTokenClient.updateOrReplace).toHaveBeenCalledWith({ + connectorId: '123', + token: null, + newToken: 'access_token brandnewaccesstoken', + tokenRequestDate: 1609502400000, + expiresInSec: 1000, + deleteExisting: false, + }); + }); + + test('creates new assertion if stored access token exists but is expired', async () => { + const createdAt = new Date('2021-01-01T08:00:00.000Z').toISOString(); + const expiresAt = new Date('2021-01-01T09:00:00.000Z').toISOString(); + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: { + id: '1', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt, + expiresAt, + }, + }); + (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); + (requestOAuthJWTToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + expiresIn: 1000, + }); + + const accessToken = await getOAuthJwtAccessToken(getOAuthJwtAccessTokenOpts); + + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(createJWTAssertion as jest.Mock).toHaveBeenCalledWith( + logger, + 'privateKey', + 'privateKeyPassword', + { + audience: 'clientId', + issuer: 'clientId', + subject: 'userIdentifierValue', + keyId: 'jwtKeyId', + } + ); + expect(requestOAuthJWTToken as jest.Mock).toHaveBeenCalledWith( + 'https://dev23432523.service-now.com/oauth_token.do', + { clientId: 'clientId', clientSecret: 'clientSecret', assertion: 'newassertion' }, + logger, + configurationUtilities + ); + expect(connectorTokenClient.updateOrReplace).toHaveBeenCalledWith({ + connectorId: '123', + token: { + id: '1', + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + createdAt, + expiresAt, + }, + tokenRequestDate: 1609502400000, + newToken: 'access_token brandnewaccesstoken', + expiresInSec: 1000, + deleteExisting: false, + }); + }); + + test('returns null and logs warning if any required fields are missing', async () => { + await asyncForEach( + ['clientId', 'jwtKeyId', 'userIdentifierValue'], + async (configField: string) => { + const accessToken = await getOAuthJwtAccessToken({ + ...getOAuthJwtAccessTokenOpts, + credentials: { + config: { ...getOAuthJwtAccessTokenOpts.credentials.config, [configField]: null }, + secrets: getOAuthJwtAccessTokenOpts.credentials.secrets, + }, + }); + expect(accessToken).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + `Missing required fields for requesting OAuth JWT access token` + ); + } + ); + + await asyncForEach(['clientSecret', 'privateKey'], async (secretsField: string) => { + const accessToken = await getOAuthJwtAccessToken({ + ...getOAuthJwtAccessTokenOpts, + credentials: { + config: getOAuthJwtAccessTokenOpts.credentials.config, + secrets: { ...getOAuthJwtAccessTokenOpts.credentials.secrets, [secretsField]: null }, + }, + }); + expect(accessToken).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + `Missing required fields for requesting OAuth JWT access token` + ); + }); + }); + + test('throws error if createJWTAssertion throws error', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: null, + }); + (createJWTAssertion as jest.Mock).mockImplementationOnce(() => { + throw new Error('createJWTAssertion error!!'); + }); + + await expect( + getOAuthJwtAccessToken(getOAuthJwtAccessTokenOpts) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"createJWTAssertion error!!"`); + }); + + test('throws error if requestOAuthJWTToken throws error', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: null, + }); + (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); + (requestOAuthJWTToken as jest.Mock).mockRejectedValueOnce( + new Error('requestOAuthJWTToken error!!') + ); + + await expect( + getOAuthJwtAccessToken(getOAuthJwtAccessTokenOpts) + ).rejects.toThrowErrorMatchingInlineSnapshot(`"requestOAuthJWTToken error!!"`); + }); + + test('logs warning if connectorTokenClient.updateOrReplace throws error', async () => { + connectorTokenClient.get.mockResolvedValueOnce({ + hasErrors: false, + connectorToken: null, + }); + (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); + (requestOAuthJWTToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + expiresIn: 1000, + }); + connectorTokenClient.updateOrReplace.mockRejectedValueOnce(new Error('updateOrReplace error')); + + const accessToken = await getOAuthJwtAccessToken(getOAuthJwtAccessTokenOpts); + + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(logger.warn).toHaveBeenCalledWith( + `Not able to update connector token for connectorId: 123 due to error: updateOrReplace error` + ); + }); + + test('gets access token if connectorId is not provided', async () => { + (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); + (requestOAuthJWTToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + expiresIn: 1000, + }); + + const accessToken = await getOAuthJwtAccessToken({ + logger, + configurationUtilities, + credentials: { + config: { + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'userIdentifierValue', + }, + secrets: { + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: 'privateKeyPassword', + }, + }, + tokenUrl: 'https://dev23432523.service-now.com/oauth_token.do', + connectorTokenClient, + }); + + expect(connectorTokenClient.get).not.toHaveBeenCalled(); + expect(connectorTokenClient.updateOrReplace).not.toHaveBeenCalled(); + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(createJWTAssertion as jest.Mock).toHaveBeenCalledWith( + logger, + 'privateKey', + 'privateKeyPassword', + { + audience: 'clientId', + issuer: 'clientId', + subject: 'userIdentifierValue', + keyId: 'jwtKeyId', + } + ); + expect(requestOAuthJWTToken as jest.Mock).toHaveBeenCalledWith( + 'https://dev23432523.service-now.com/oauth_token.do', + { clientId: 'clientId', clientSecret: 'clientSecret', assertion: 'newassertion' }, + logger, + configurationUtilities + ); + }); + + test('gets access token if connectorTokenClient is not provided', async () => { + (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); + (requestOAuthJWTToken as jest.Mock).mockResolvedValueOnce({ + tokenType: 'access_token', + accessToken: 'brandnewaccesstoken', + expiresIn: 1000, + }); + + const accessToken = await getOAuthJwtAccessToken({ + connectorId: '123', + logger, + configurationUtilities, + credentials: { + config: { + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'userIdentifierValue', + }, + secrets: { + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: 'privateKeyPassword', + }, + }, + tokenUrl: 'https://dev23432523.service-now.com/oauth_token.do', + }); + + expect(connectorTokenClient.get).not.toHaveBeenCalled(); + expect(connectorTokenClient.updateOrReplace).not.toHaveBeenCalled(); + expect(accessToken).toEqual('access_token brandnewaccesstoken'); + expect(createJWTAssertion as jest.Mock).toHaveBeenCalledWith( + logger, + 'privateKey', + 'privateKeyPassword', + { + audience: 'clientId', + issuer: 'clientId', + subject: 'userIdentifierValue', + keyId: 'jwtKeyId', + } + ); + expect(requestOAuthJWTToken as jest.Mock).toHaveBeenCalledWith( + 'https://dev23432523.service-now.com/oauth_token.do', + { clientId: 'clientId', clientSecret: 'clientSecret', assertion: 'newassertion' }, + logger, + configurationUtilities + ); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_jwt_access_token.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_jwt_access_token.ts new file mode 100644 index 000000000000000..1233a61c0f3c84b --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_jwt_access_token.ts @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { ConnectorToken, ConnectorTokenClientContract } from '../../types'; +import { createJWTAssertion } from './create_jwt_assertion'; +import { requestOAuthJWTToken } from './request_oauth_jwt_token'; + +export interface GetOAuthJwtConfig { + clientId: string; + jwtKeyId: string; + userIdentifierValue: string; +} + +export interface GetOAuthJwtSecrets { + clientSecret: string; + privateKey: string; + privateKeyPassword: string | null; +} + +interface GetOAuthJwtAccessTokenOpts { + connectorId?: string; + tokenUrl: string; + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; + credentials: { + config: GetOAuthJwtConfig; + secrets: GetOAuthJwtSecrets; + }; + connectorTokenClient?: ConnectorTokenClientContract; +} + +export const getOAuthJwtAccessToken = async ({ + connectorId, + logger, + tokenUrl, + configurationUtilities, + credentials, + connectorTokenClient, +}: GetOAuthJwtAccessTokenOpts) => { + const { clientId, jwtKeyId, userIdentifierValue } = credentials.config; + const { clientSecret, privateKey, privateKeyPassword } = credentials.secrets; + + if (!clientId || !clientSecret || !jwtKeyId || !privateKey || !userIdentifierValue) { + logger.warn(`Missing required fields for requesting OAuth JWT access token`); + return null; + } + + let accessToken: string; + let connectorToken: ConnectorToken | null = null; + let hasErrors: boolean = false; + + if (connectorId && connectorTokenClient) { + // Check if there is a token stored for this connector + const { connectorToken: token, hasErrors: errors } = await connectorTokenClient.get({ + connectorId, + }); + connectorToken = token; + hasErrors = errors; + } + + if (connectorToken === null || Date.parse(connectorToken.expiresAt) <= Date.now()) { + // generate a new assertion + const assertion = createJWTAssertion(logger, privateKey, privateKeyPassword, { + audience: clientId, + issuer: clientId, + subject: userIdentifierValue, + keyId: jwtKeyId, + }); + + // Save the time before requesting token so we can use it to calculate expiration + const requestTokenStart = Date.now(); + + // request access token with jwt assertion + const tokenResult = await requestOAuthJWTToken( + tokenUrl, + { + clientId, + clientSecret, + assertion, + }, + logger, + configurationUtilities + ); + accessToken = `${tokenResult.tokenType} ${tokenResult.accessToken}`; + + // try to update connector_token SO + if (connectorId && connectorTokenClient) { + try { + await connectorTokenClient.updateOrReplace({ + connectorId, + token: connectorToken, + newToken: accessToken, + tokenRequestDate: requestTokenStart, + expiresInSec: tokenResult.expiresIn, + deleteExisting: hasErrors, + }); + } catch (err) { + logger.warn( + `Not able to update connector token for connectorId: ${connectorId} due to error: ${err.message}` + ); + } + } + } else { + // use existing valid token + accessToken = connectorToken.token; + } + return accessToken; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts index 1d1c2c46cb0e49c..fe6fc3492492aec 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts @@ -5,48 +5,54 @@ * 2.0. */ -jest.mock('nodemailer', () => ({ - createTransport: jest.fn(), -})); - -jest.mock('./send_email_graph_api', () => ({ - sendEmailGraphApi: jest.fn(), -})); -jest.mock('./request_oauth_client_credentials_token', () => ({ - requestOAuthClientCredentialsToken: jest.fn(), -})); - +import axios from 'axios'; import { Logger } from '@kbn/core/server'; import { sendEmail } from './send_email'; -import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; import nodemailer from 'nodemailer'; import { ProxySettings } from '../../types'; import { actionsConfigMock } from '../../actions_config.mock'; import { CustomHostSettings } from '../../config'; import { sendEmailGraphApi } from './send_email_graph_api'; -import { requestOAuthClientCredentialsToken } from './request_oauth_client_credentials_token'; -import { ConnectorTokenClient } from './connector_token_client'; -import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { getOAuthClientCredentialsAccessToken } from './get_oauth_client_credentials_access_token'; import { connectorTokenClientMock } from './connector_token_client.mock'; +jest.mock('nodemailer', () => ({ + createTransport: jest.fn(), +})); +jest.mock('./send_email_graph_api', () => ({ + sendEmailGraphApi: jest.fn(), +})); +jest.mock('./get_oauth_client_credentials_access_token', () => ({ + getOAuthClientCredentialsAccessToken: jest.fn(), +})); + +jest.mock('axios'); +const mockAxiosInstanceInterceptor = { + request: { eject: jest.fn(), use: jest.fn() }, + response: { eject: jest.fn(), use: jest.fn() }, +}; + const createTransportMock = nodemailer.createTransport as jest.Mock; const sendMailMockResult = { result: 'does not matter' }; const sendMailMock = jest.fn(); const mockLogger = loggingSystemMock.create().get() as jest.Mocked; -const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); -const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); -const connectorTokenClient = new ConnectorTokenClient({ - unsecuredSavedObjectsClient, - encryptedSavedObjectsClient, - logger: mockLogger, -}); +const connectorTokenClient = connectorTokenClientMock.create(); describe('send_email module', () => { beforeEach(() => { jest.resetAllMocks(); createTransportMock.mockReturnValue({ sendMail: sendMailMock }); sendMailMock.mockResolvedValue(sendMailMockResult); + + axios.create = jest.fn(() => { + const actual = jest.requireActual('axios'); + return { + ...actual.create, + interceptors: mockAxiosInstanceInterceptor, + }; + }); }); test('handles authenticated email using service', async () => { @@ -92,315 +98,40 @@ describe('send_email module', () => { test('uses OAuth 2.0 Client Credentials authentication for email using "exchange_server" service', async () => { const sendEmailGraphApiMock = sendEmailGraphApi as jest.Mock; - const requestOAuthClientCredentialsTokenMock = requestOAuthClientCredentialsToken as jest.Mock; + const getOAuthClientCredentialsAccessTokenMock = + getOAuthClientCredentialsAccessToken as jest.Mock; const sendEmailOptions = getSendEmailOptions({ transport: { service: 'exchange_server', clientId: '123456', + tenantId: '98765', clientSecret: 'sdfhkdsjhfksdjfh', }, }); - requestOAuthClientCredentialsTokenMock.mockReturnValueOnce({ - tokenType: 'Bearer', - accessToken: 'dfjsdfgdjhfgsjdf', - expiresIn: 123, - }); + getOAuthClientCredentialsAccessTokenMock.mockReturnValueOnce(`Bearer dfjsdfgdjhfgsjdf`); const date = new Date(); date.setDate(date.getDate() + 5); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'connector_token', - references: [], - attributes: { - connectorId: '123', - expiresAt: date.toISOString(), - tokenType: 'access_token', - token: '11111111', - }, - }); - sendEmailGraphApiMock.mockReturnValue({ status: 202, }); - unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ - total: 0, - saved_objects: [], - per_page: 500, - page: 1, - }); await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); - requestOAuthClientCredentialsTokenMock.mock.calls[0].pop(); - expect(requestOAuthClientCredentialsTokenMock.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - "https://login.microsoftonline.com/undefined/oauth2/v2.0/token", - Object { - "context": Array [], - "debug": [MockFunction], - "error": [MockFunction], - "fatal": [MockFunction], - "get": [MockFunction], - "info": [MockFunction], - "log": [MockFunction], - "trace": [MockFunction], - "warn": [MockFunction], - }, - Object { - "clientId": "123456", - "clientSecret": "sdfhkdsjhfksdjfh", - "scope": "https://graph.microsoft.com/.default", - }, - ] - `); - - delete sendEmailGraphApiMock.mock.calls[0][0].options.configurationUtilities; - sendEmailGraphApiMock.mock.calls[0].pop(); - expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "graphApiUrl": undefined, - "headers": Object { - "Authorization": "Bearer dfjsdfgdjhfgsjdf", - "Content-Type": "application/json", - }, - "messageHTML": "

    a message

    - ", - "options": Object { - "connectorId": "1", - "content": Object { - "message": "a message", - "subject": "a subject", - }, - "hasAuth": true, - "routing": Object { - "bcc": Array [], - "cc": Array [ - "bob@example.com", - "robert@example.com", - ], - "from": "fred@example.com", - "to": Array [ - "jim@example.com", - ], - }, - "transport": Object { - "clientId": "123456", - "clientSecret": "sdfhkdsjhfksdjfh", - "password": "changeme", - "service": "exchange_server", - "user": "elastic", - }, - }, - }, - Object { - "context": Array [], - "debug": [MockFunction], - "error": [MockFunction], - "fatal": [MockFunction], - "get": [MockFunction], - "info": [MockFunction], - "log": [MockFunction], - "trace": [MockFunction], - "warn": [MockFunction], - }, - ] - `); - - expect(unsecuredSavedObjectsClient.create.mock.calls.length).toBe(1); - }); - - test('uses existing "access_token" from "connector_token" SO for authentication for email using "exchange_server" service', async () => { - const sendEmailGraphApiMock = sendEmailGraphApi as jest.Mock; - const requestOAuthClientCredentialsTokenMock = requestOAuthClientCredentialsToken as jest.Mock; - const sendEmailOptions = getSendEmailOptions({ - transport: { - service: 'exchange_server', - clientId: '123456', - clientSecret: 'sdfhkdsjhfksdjfh', - }, - }); - requestOAuthClientCredentialsTokenMock.mockReturnValueOnce({ - tokenType: 'Bearer', - accessToken: 'dfjsdfgdjhfgsjdf', - expiresIn: 123, - }); - - sendEmailGraphApiMock.mockReturnValue({ - status: 202, - }); - const date = new Date(); - date.setDate(date.getDate() + 5); - - unsecuredSavedObjectsClient.checkConflicts.mockResolvedValueOnce({ - errors: [], - }); - unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ - total: 2, - saved_objects: [ - { - id: '1', - score: 1, - type: 'connector_token', - references: [], - attributes: { - connectorId: '123', - expiresAt: date.toISOString(), - tokenType: 'access_token', - }, - }, - ], - per_page: 500, - page: 1, - }); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ - id: '1', - type: 'connector_token', - references: [], - attributes: { - token: '11111111', + expect(getOAuthClientCredentialsAccessTokenMock).toHaveBeenCalledWith({ + configurationUtilities: sendEmailOptions.configurationUtilities, + connectorId: '1', + connectorTokenClient, + credentials: { + config: { clientId: '123456', tenantId: '98765' }, + secrets: { clientSecret: 'sdfhkdsjhfksdjfh' }, }, + logger: mockLogger, + oAuthScope: 'https://graph.microsoft.com/.default', + tokenUrl: 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', }); - await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); - expect(requestOAuthClientCredentialsTokenMock.mock.calls.length).toBe(0); - delete sendEmailGraphApiMock.mock.calls[0][0].options.configurationUtilities; sendEmailGraphApiMock.mock.calls[0].pop(); - expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "graphApiUrl": undefined, - "headers": Object { - "Authorization": "11111111", - "Content-Type": "application/json", - }, - "messageHTML": "

    a message

    - ", - "options": Object { - "connectorId": "1", - "content": Object { - "message": "a message", - "subject": "a subject", - }, - "hasAuth": true, - "routing": Object { - "bcc": Array [], - "cc": Array [ - "bob@example.com", - "robert@example.com", - ], - "from": "fred@example.com", - "to": Array [ - "jim@example.com", - ], - }, - "transport": Object { - "clientId": "123456", - "clientSecret": "sdfhkdsjhfksdjfh", - "password": "changeme", - "service": "exchange_server", - "user": "elastic", - }, - }, - }, - Object { - "context": Array [], - "debug": [MockFunction], - "error": [MockFunction], - "fatal": [MockFunction], - "get": [MockFunction], - "info": [MockFunction], - "log": [MockFunction], - "trace": [MockFunction], - "warn": [MockFunction], - }, - ] - `); - - expect(unsecuredSavedObjectsClient.create.mock.calls.length).toBe(0); - }); - - test('request the new token and update existing "access_token" when it is expired for "exchange_server" email service', async () => { - const sendEmailGraphApiMock = sendEmailGraphApi as jest.Mock; - const requestOAuthClientCredentialsTokenMock = requestOAuthClientCredentialsToken as jest.Mock; - const sendEmailOptions = getSendEmailOptions({ - transport: { - service: 'exchange_server', - clientId: '123456', - clientSecret: 'sdfhkdsjhfksdjfh', - }, - }); - requestOAuthClientCredentialsTokenMock.mockReturnValueOnce({ - tokenType: 'Bearer', - accessToken: 'dfjsdfgdjhfgsjdf', - expiresIn: 123, - }); - - sendEmailGraphApiMock.mockReturnValue({ - status: 202, - }); - const date = new Date(); - date.setDate(date.getDate() - 5); - - unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ - total: 2, - saved_objects: [ - { - id: '1', - score: 1, - type: 'connector_token', - references: [], - attributes: { - connectorId: '123', - expiresAt: date.toISOString(), - tokenType: 'access_token', - }, - }, - ], - per_page: 500, - page: 1, - }); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ - id: '1', - type: 'connector_token', - references: [], - attributes: { - token: '11111111', - }, - }); - - unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ - id: '1', - type: 'connector_token', - references: [], - attributes: { - connectorId: '123', - expiresAt: date.toISOString(), - tokenType: 'access_token', - token: '11111111', - }, - }); - unsecuredSavedObjectsClient.checkConflicts.mockResolvedValueOnce({ - errors: [], - }); - - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'connector_token', - references: [], - attributes: { - connectorId: '123', - expiresAt: date.toISOString(), - tokenType: 'access_token', - token: '11111111', - }, - }); - - await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); - expect(requestOAuthClientCredentialsTokenMock.mock.calls.length).toBe(1); - - delete sendEmailGraphApiMock.mock.calls[0][0].options.configurationUtilities; sendEmailGraphApiMock.mock.calls[0].pop(); expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -435,6 +166,7 @@ describe('send_email module', () => { "clientSecret": "sdfhkdsjhfksdjfh", "password": "changeme", "service": "exchange_server", + "tenantId": "98765", "user": "elastic", }, }, @@ -452,209 +184,42 @@ describe('send_email module', () => { }, ] `); - - expect(unsecuredSavedObjectsClient.create.mock.calls.length).toBe(1); }); - test('sending email for "exchange_server" wont fail if connectorTokenClient throw the errors, just log warning message', async () => { + test('throws error if null access token returned when using OAuth 2.0 Client Credentials authentication', async () => { const sendEmailGraphApiMock = sendEmailGraphApi as jest.Mock; - const requestOAuthClientCredentialsTokenMock = requestOAuthClientCredentialsToken as jest.Mock; + const getOAuthClientCredentialsAccessTokenMock = + getOAuthClientCredentialsAccessToken as jest.Mock; const sendEmailOptions = getSendEmailOptions({ transport: { service: 'exchange_server', clientId: '123456', + tenantId: '98765', clientSecret: 'sdfhkdsjhfksdjfh', }, }); - requestOAuthClientCredentialsTokenMock.mockReturnValueOnce({ - tokenType: 'Bearer', - accessToken: 'dfjsdfgdjhfgsjdf', - expiresIn: 123, - }); - - sendEmailGraphApiMock.mockReturnValue({ - status: 202, - }); - const date = new Date(); - date.setDate(date.getDate() + 5); + getOAuthClientCredentialsAccessTokenMock.mockReturnValueOnce(null); - unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ - total: 0, - saved_objects: [], - per_page: 500, - page: 1, - }); - unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail')); - - await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); - expect(requestOAuthClientCredentialsTokenMock.mock.calls.length).toBe(1); - expect(unsecuredSavedObjectsClient.create.mock.calls.length).toBe(1); - expect(mockLogger.warn.mock.calls[0]).toMatchObject([ - `Not able to update connector token for connectorId: 1 due to error: Fail`, - ]); - - delete sendEmailGraphApiMock.mock.calls[0][0].options.configurationUtilities; - sendEmailGraphApiMock.mock.calls[0].pop(); - expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "graphApiUrl": undefined, - "headers": Object { - "Authorization": "Bearer dfjsdfgdjhfgsjdf", - "Content-Type": "application/json", - }, - "messageHTML": "

    a message

    - ", - "options": Object { - "connectorId": "1", - "content": Object { - "message": "a message", - "subject": "a subject", - }, - "hasAuth": true, - "routing": Object { - "bcc": Array [], - "cc": Array [ - "bob@example.com", - "robert@example.com", - ], - "from": "fred@example.com", - "to": Array [ - "jim@example.com", - ], - }, - "transport": Object { - "clientId": "123456", - "clientSecret": "sdfhkdsjhfksdjfh", - "password": "changeme", - "service": "exchange_server", - "user": "elastic", - }, - }, - }, - Object { - "context": Array [], - "debug": [MockFunction], - "error": [MockFunction] { - "calls": Array [ - Array [ - "Failed to create connector_token for connectorId \\"1\\" and tokenType: \\"access_token\\". Error: Fail", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], - }, - "fatal": [MockFunction], - "get": [MockFunction], - "info": [MockFunction], - "log": [MockFunction], - "trace": [MockFunction], - "warn": [MockFunction] { - "calls": Array [ - Array [ - "Not able to update connector token for connectorId: 1 due to error: Fail", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], - }, - }, - ] - `); - }); + await expect(() => + sendEmail(mockLogger, sendEmailOptions, connectorTokenClient) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unable to retrieve access token for connectorId: 1"` + ); - test('delete duplication tokens if connectorTokenClient get method has the errors, like decription error', async () => { - const sendEmailGraphApiMock = sendEmailGraphApi as jest.Mock; - const requestOAuthClientCredentialsTokenMock = requestOAuthClientCredentialsToken as jest.Mock; - const sendEmailOptions = getSendEmailOptions({ - transport: { - service: 'exchange_server', - clientId: '123456', - clientSecret: 'sdfhkdsjhfksdjfh', + expect(getOAuthClientCredentialsAccessTokenMock).toHaveBeenCalledWith({ + configurationUtilities: sendEmailOptions.configurationUtilities, + connectorId: '1', + connectorTokenClient, + credentials: { + config: { clientId: '123456', tenantId: '98765' }, + secrets: { clientSecret: 'sdfhkdsjhfksdjfh' }, }, - }); - requestOAuthClientCredentialsTokenMock.mockReturnValueOnce({ - tokenType: 'Bearer', - accessToken: 'dfjsdfgdjhfgsjdf', - expiresIn: 123, + logger: mockLogger, + oAuthScope: 'https://graph.microsoft.com/.default', + tokenUrl: 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', }); - sendEmailGraphApiMock.mockReturnValue({ - status: 202, - }); - const date = new Date(); - date.setDate(date.getDate() + 5); - - const connectorTokenClientM = connectorTokenClientMock.create(); - connectorTokenClientM.get.mockResolvedValueOnce({ - hasErrors: true, - connectorToken: null, - }); - - await sendEmail(mockLogger, sendEmailOptions, connectorTokenClientM); - expect(requestOAuthClientCredentialsTokenMock.mock.calls.length).toBe(1); - expect(connectorTokenClientM.updateOrReplace.mock.calls.length).toBe(1); - - delete sendEmailGraphApiMock.mock.calls[0][0].options.configurationUtilities; - sendEmailGraphApiMock.mock.calls[0].pop(); - expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "graphApiUrl": undefined, - "headers": Object { - "Authorization": "Bearer dfjsdfgdjhfgsjdf", - "Content-Type": "application/json", - }, - "messageHTML": "

    a message

    - ", - "options": Object { - "connectorId": "1", - "content": Object { - "message": "a message", - "subject": "a subject", - }, - "hasAuth": true, - "routing": Object { - "bcc": Array [], - "cc": Array [ - "bob@example.com", - "robert@example.com", - ], - "from": "fred@example.com", - "to": Array [ - "jim@example.com", - ], - }, - "transport": Object { - "clientId": "123456", - "clientSecret": "sdfhkdsjhfksdjfh", - "password": "changeme", - "service": "exchange_server", - "user": "elastic", - }, - }, - }, - Object { - "context": Array [], - "debug": [MockFunction], - "error": [MockFunction], - "fatal": [MockFunction], - "get": [MockFunction], - "info": [MockFunction], - "log": [MockFunction], - "trace": [MockFunction], - "warn": [MockFunction], - }, - ] - `); + expect(sendEmailGraphApiMock).not.toHaveBeenCalled(); }); test('handles unauthenticated email using not secure host/port', async () => { @@ -1090,6 +655,83 @@ describe('send_email module', () => { ] `); }); + + test('deletes saved access tokens if 4xx response received', async () => { + const createAxiosInstanceMock = axios.create as jest.Mock; + const sendEmailOptions = getSendEmailOptions({ + transport: { + service: 'exchange_server', + clientId: '123456', + tenantId: '98765', + clientSecret: 'sdfhkdsjhfksdjfh', + }, + }); + (getOAuthClientCredentialsAccessToken as jest.Mock).mockResolvedValueOnce( + 'Bearer clienttokentokentoken' + ); + + await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + expect(createAxiosInstanceMock).toHaveBeenCalledTimes(1); + expect(createAxiosInstanceMock).toHaveBeenCalledWith(); + expect(mockAxiosInstanceInterceptor.response.use).toHaveBeenCalledTimes(1); + + const mockResponseCallback = (mockAxiosInstanceInterceptor.response.use as jest.Mock).mock + .calls[0][1]; + + const errorResponse = { + response: { + status: 403, + statusText: 'Forbidden', + data: { + error: { + message: 'Insufficient rights to query records', + detail: 'Field(s) present in the query do not have permission to be read', + }, + status: 'failure', + }, + }, + }; + + await expect(() => mockResponseCallback(errorResponse)).rejects.toEqual(errorResponse); + + expect(connectorTokenClient.deleteConnectorTokens).toHaveBeenCalledWith({ + connectorId: '1', + }); + }); + + test('does not delete saved access token if not 4xx error response received', async () => { + const createAxiosInstanceMock = axios.create as jest.Mock; + const sendEmailOptions = getSendEmailOptions({ + transport: { + service: 'exchange_server', + clientId: '123456', + tenantId: '98765', + clientSecret: 'sdfhkdsjhfksdjfh', + }, + }); + (getOAuthClientCredentialsAccessToken as jest.Mock).mockResolvedValueOnce( + 'Bearer clienttokentokentoken' + ); + + await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + expect(createAxiosInstanceMock).toHaveBeenCalledTimes(1); + expect(createAxiosInstanceMock).toHaveBeenCalledWith(); + expect(mockAxiosInstanceInterceptor.response.use).toHaveBeenCalledTimes(1); + + const mockResponseCallback = (mockAxiosInstanceInterceptor.response.use as jest.Mock).mock + .calls[0][1]; + + const errorResponse = { + response: { + status: 500, + statusText: 'Server error', + }, + }; + + await expect(() => mockResponseCallback(errorResponse)).rejects.toEqual(errorResponse); + + expect(connectorTokenClient.deleteConnectorTokens).not.toHaveBeenCalled(); + }); }); function getSendEmailOptions( diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts index 983846adc71e0ca..2fee4dd8b377deb 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts @@ -5,6 +5,7 @@ * 2.0. */ +import axios, { AxiosResponse } from 'axios'; // info on nodemailer: https://nodemailer.com/about/ import nodemailer from 'nodemailer'; import { default as MarkdownIt } from 'markdown-it'; @@ -14,9 +15,9 @@ import { ActionsConfigurationUtilities } from '../../actions_config'; import { CustomHostSettings } from '../../config'; import { getNodeSSLOptions, getSSLSettingsFromConfig } from './get_node_ssl_options'; import { sendEmailGraphApi } from './send_email_graph_api'; -import { requestOAuthClientCredentialsToken } from './request_oauth_client_credentials_token'; import { ConnectorTokenClientContract, ProxySettings } from '../../types'; import { AdditionalEmailServices } from '../../../common'; +import { getOAuthClientCredentialsAccessToken } from './get_oauth_client_credentials_access_token'; // an email "service" which doesn't actually send, just returns what it would send export const JSON_TRANSPORT_SERVICE = '__json'; @@ -77,7 +78,7 @@ export async function sendEmail( } // send an email using MS Exchange Graph API -async function sendEmailWithExchange( +export async function sendEmailWithExchange( logger: Logger, options: SendEmailOptions, messageHTML: string, @@ -86,46 +87,57 @@ async function sendEmailWithExchange( const { transport, configurationUtilities, connectorId } = options; const { clientId, clientSecret, tenantId, oauthTokenUrl } = transport; - let accessToken: string; - - const { connectorToken, hasErrors } = await connectorTokenClient.get({ connectorId }); - if (connectorToken === null || Date.parse(connectorToken.expiresAt) <= Date.now()) { - // request new access token for microsoft exchange online server with Graph API scope - const tokenResult = await requestOAuthClientCredentialsToken( - oauthTokenUrl ?? `${EXCHANGE_ONLINE_SERVER_HOST}/${tenantId}/oauth2/v2.0/token`, - logger, - { - scope: GRAPH_API_OAUTH_SCOPE, - clientId, - clientSecret, + const accessToken = await getOAuthClientCredentialsAccessToken({ + connectorId, + logger, + configurationUtilities, + credentials: { + config: { + clientId: clientId as string, + tenantId: tenantId as string, + }, + secrets: { + clientSecret: clientSecret as string, }, - configurationUtilities - ); - accessToken = `${tokenResult.tokenType} ${tokenResult.accessToken}`; + }, + oAuthScope: GRAPH_API_OAUTH_SCOPE, + tokenUrl: oauthTokenUrl ?? `${EXCHANGE_ONLINE_SERVER_HOST}/${tenantId}/oauth2/v2.0/token`, + connectorTokenClient, + }); - // try to update connector_token SO - try { - await connectorTokenClient.updateOrReplace({ - connectorId, - token: connectorToken, - newToken: accessToken, - expiresInSec: tokenResult.expiresIn, - deleteExisting: hasErrors, - }); - } catch (err) { - logger.warn( - `Not able to update connector token for connectorId: ${connectorId} due to error: ${err.message}` - ); - } - } else { - // use existing valid token - accessToken = connectorToken.token; + if (!accessToken) { + throw new Error(`Unable to retrieve access token for connectorId: ${connectorId}`); } + const headers = { 'Content-Type': 'application/json', Authorization: accessToken, }; + const axiosInstance = axios.create(); + axiosInstance.interceptors.response.use( + async (response: AxiosResponse) => { + // Look for 4xx errors that indicate something is wrong with the request + // We don't know for sure that it is an access token issue but remove saved + // token just to be sure + if (response.status >= 400 && response.status < 500) { + await connectorTokenClient.deleteConnectorTokens({ connectorId }); + } + return response; + }, + async (error) => { + const statusCode = error?.response?.status; + + // Look for 4xx errors that indicate something is wrong with the request + // We don't know for sure that it is an access token issue but remove saved + // token just to be sure + if (statusCode >= 400 && statusCode < 500) { + await connectorTokenClient.deleteConnectorTokens({ connectorId }); + } + return Promise.reject(error); + } + ); + return await sendEmailGraphApi( { options, @@ -134,7 +146,8 @@ async function sendEmailWithExchange( graphApiUrl: configurationUtilities.getMicrosoftGraphApiUrl(), }, logger, - configurationUtilities + configurationUtilities, + axiosInstance ); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email_graph_api.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email_graph_api.ts index c16cd884cb75349..6475426143af713 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email_graph_api.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email_graph_api.ts @@ -7,7 +7,7 @@ // @ts-expect-error missing type def import stringify from 'json-stringify-safe'; -import axios, { AxiosResponse } from 'axios'; +import axios, { AxiosInstance, AxiosResponse } from 'axios'; import { Logger } from '@kbn/core/server'; import { request } from './axios_utils'; import { ActionsConfigurationUtilities } from '../../actions_config'; @@ -25,11 +25,13 @@ const MICROSOFT_GRAPH_API_HOST = 'https://graph.microsoft.com/v1.0'; export async function sendEmailGraphApi( sendEmailOptions: SendEmailGraphApiOptions, logger: Logger, - configurationUtilities: ActionsConfigurationUtilities + configurationUtilities: ActionsConfigurationUtilities, + axiosInstance?: AxiosInstance ): Promise { const { options, headers, messageHTML, graphApiUrl } = sendEmailOptions; - const axiosInstance = axios.create(); + // Create a new axios instance if one is not provided + axiosInstance = axiosInstance ?? axios.create(); // POST /users/{id | userPrincipalName}/sendMail const res = await request({ diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts index dae4e59728a0ca0..fcd2023dc8e2716 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import axios, { AxiosError } from 'axios'; +import axios from 'axios'; import { Logger } from '@kbn/core/server'; import { loggingSystemMock } from '@kbn/core/server/mocks'; @@ -14,19 +14,15 @@ import { createServiceError, getPushedDate, throwIfSubActionIsNotSupported, - getAccessToken, getAxiosInstance, } from './utils'; +import type { ResponseError } from './types'; import { connectorTokenClientMock } from '../lib/connector_token_client.mock'; import { actionsConfigMock } from '../../actions_config.mock'; -import { createJWTAssertion } from '../lib/create_jwt_assertion'; -import { requestOAuthJWTToken } from '../lib/request_oauth_jwt_token'; +import { getOAuthJwtAccessToken } from '../lib/get_oauth_jwt_access_token'; -jest.mock('../lib/create_jwt_assertion', () => ({ - createJWTAssertion: jest.fn(), -})); -jest.mock('../lib/request_oauth_jwt_token', () => ({ - requestOAuthJWTToken: jest.fn(), +jest.mock('../lib/get_oauth_jwt_access_token', () => ({ + getOAuthJwtAccessToken: jest.fn(), })); jest.mock('axios', () => ({ @@ -78,7 +74,7 @@ describe('utils', () => { const axiosError = { message: 'An error occurred', response: { data: { error: { message: 'Denied', detail: 'no access' } } }, - } as AxiosError; + } as ResponseError; expect(createServiceError(axiosError, 'Unable to do action').message).toBe( '[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: Denied: no access' @@ -89,7 +85,7 @@ describe('utils', () => { const axiosError = { message: 'An error occurred', response: { data: { error: null } }, - } as AxiosError; + } as ResponseError; expect(createServiceError(axiosError, 'Unable to do action').message).toBe( '[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: unknown: no error in error response' @@ -195,18 +191,7 @@ describe('utils', () => { }); }); - test('creates axios instance with interceptor when isOAuth is true and OAuth fields are defined', () => { - connectorTokenClient.get.mockResolvedValueOnce({ - hasErrors: false, - connectorToken: { - id: '1', - connectorId: '123', - tokenType: 'access_token', - token: 'testtokenvalue', - createdAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 10000000000).toISOString(), - }, - }); + test('creates axios instance with interceptor when isOAuth is true and OAuth fields are defined', async () => { getAxiosInstance({ connectorId: '123', logger, @@ -235,206 +220,198 @@ describe('utils', () => { expect(createAxiosInstanceMock).toHaveBeenCalledTimes(1); expect(createAxiosInstanceMock).toHaveBeenCalledWith(); expect(axiosInstanceMock.interceptors.request.use).toHaveBeenCalledTimes(1); - }); - }); - describe('getAccessToken', () => { - const getAccessTokenOpts = { - connectorId: '123', - logger, - configurationUtilities, - credentials: { - config: { - apiUrl: 'https://servicenow', - usesTableApi: true, - isOAuth: true, - clientId: 'clientId', - jwtKeyId: 'jwtKeyId', - userIdentifierValue: 'userIdentifierValue', - }, - secrets: { - clientSecret: 'clientSecret', - privateKey: 'privateKey', - privateKeyPassword: 'privateKeyPassword', - username: null, - password: null, - }, - }, - snServiceUrl: 'https://dev23432523.service-now.com', - connectorTokenClient, - }; - beforeEach(() => { - jest.resetAllMocks(); - jest.clearAllMocks(); - }); + (getOAuthJwtAccessToken as jest.Mock).mockResolvedValueOnce('Bearer tokentokentoken'); - test('uses stored access token if it exists', async () => { - connectorTokenClient.get.mockResolvedValueOnce({ - hasErrors: false, - connectorToken: { - id: '1', - connectorId: '123', - tokenType: 'access_token', - token: 'testtokenvalue', - createdAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 10000000000).toISOString(), - }, + const mockRequestCallback = (axiosInstanceMock.interceptors.request.use as jest.Mock).mock + .calls[0][0]; + expect(await mockRequestCallback({ headers: {} })).toEqual({ + headers: { Authorization: 'Bearer tokentokentoken' }, }); - const accessToken = await getAccessToken(getAccessTokenOpts); - expect(accessToken).toEqual('testtokenvalue'); - expect(createJWTAssertion as jest.Mock).not.toHaveBeenCalled(); - expect(requestOAuthJWTToken as jest.Mock).not.toHaveBeenCalled(); + expect(getOAuthJwtAccessToken as jest.Mock).toHaveBeenCalledWith({ + connectorId: '123', + logger, + configurationUtilities, + credentials: { + config: { + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'userIdentifierValue', + }, + secrets: { + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: null, + }, + }, + tokenUrl: 'https://dev23432523.service-now.com/oauth_token.do', + connectorTokenClient, + }); }); - test('creates new assertion if stored access token does not exist', async () => { - connectorTokenClient.get.mockResolvedValueOnce({ - hasErrors: false, - connectorToken: null, - }); - (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); - (requestOAuthJWTToken as jest.Mock).mockResolvedValueOnce({ - tokenType: 'access_token', - accessToken: 'brandnewaccesstoken', - expiresIn: 1000, + test('throws expected error if getOAuthJwtAccessToken returns null access token', async () => { + getAxiosInstance({ + connectorId: '123', + logger, + configurationUtilities, + credentials: { + config: { + apiUrl: 'https://servicenow', + usesTableApi: true, + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'userIdentifierValue', + }, + secrets: { + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: null, + username: null, + password: null, + }, + }, + snServiceUrl: 'https://dev23432523.service-now.com', + connectorTokenClient, }); + expect(createAxiosInstanceMock).toHaveBeenCalledTimes(1); + expect(createAxiosInstanceMock).toHaveBeenCalledWith(); + expect(axiosInstanceMock.interceptors.request.use).toHaveBeenCalledTimes(1); - const accessToken = await getAccessToken(getAccessTokenOpts); + (getOAuthJwtAccessToken as jest.Mock).mockResolvedValueOnce(null); - expect(accessToken).toEqual('access_token brandnewaccesstoken'); - expect(createJWTAssertion as jest.Mock).toHaveBeenCalledWith( - logger, - 'privateKey', - 'privateKeyPassword', - { - audience: 'clientId', - issuer: 'clientId', - subject: 'userIdentifierValue', - keyId: 'jwtKeyId', - } - ); - expect(requestOAuthJWTToken as jest.Mock).toHaveBeenCalledWith( - 'https://dev23432523.service-now.com/oauth_token.do', - { clientId: 'clientId', clientSecret: 'clientSecret', assertion: 'newassertion' }, - logger, - configurationUtilities + const mockRequestCallback = (axiosInstanceMock.interceptors.request.use as jest.Mock).mock + .calls[0][0]; + + await expect(() => + mockRequestCallback({ headers: {} }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unable to retrieve access token for connectorId: 123"` ); - expect(connectorTokenClient.updateOrReplace).toHaveBeenCalledWith({ + + expect(getOAuthJwtAccessToken as jest.Mock).toHaveBeenCalledWith({ connectorId: '123', - token: null, - newToken: 'access_token brandnewaccesstoken', - expiresInSec: 1000, - deleteExisting: false, + logger, + configurationUtilities, + credentials: { + config: { + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'userIdentifierValue', + }, + secrets: { + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: null, + }, + }, + tokenUrl: 'https://dev23432523.service-now.com/oauth_token.do', + connectorTokenClient, }); }); - test('creates new assertion if stored access token exists but is expired', async () => { - const createdAt = new Date().toISOString(); - const expiresAt = new Date(Date.now() - 100).toISOString(); - connectorTokenClient.get.mockResolvedValueOnce({ - hasErrors: false, - connectorToken: { - id: '1', - connectorId: '123', - tokenType: 'access_token', - token: 'testtokenvalue', - createdAt, - expiresAt, + test('deletes saved access tokens if 4xx response received', async () => { + getAxiosInstance({ + connectorId: '123', + logger, + configurationUtilities, + credentials: { + config: { + apiUrl: 'https://servicenow', + usesTableApi: true, + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'userIdentifierValue', + }, + secrets: { + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: null, + username: null, + password: null, + }, }, + snServiceUrl: 'https://dev23432523.service-now.com', + connectorTokenClient, }); - (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); - (requestOAuthJWTToken as jest.Mock).mockResolvedValueOnce({ - tokenType: 'access_token', - accessToken: 'brandnewaccesstoken', - expiresIn: 1000, - }); + expect(createAxiosInstanceMock).toHaveBeenCalledTimes(1); + expect(createAxiosInstanceMock).toHaveBeenCalledWith(); + expect(axiosInstanceMock.interceptors.request.use).toHaveBeenCalledTimes(1); + expect(axiosInstanceMock.interceptors.response.use).toHaveBeenCalledTimes(1); + + (getOAuthJwtAccessToken as jest.Mock).mockResolvedValueOnce('Bearer tokentokentoken'); + + const mockResponseCallback = (axiosInstanceMock.interceptors.response.use as jest.Mock).mock + .calls[0][1]; + + const errorResponse = { + response: { + status: 403, + statusText: 'Forbidden', + data: { + error: { + message: 'Insufficient rights to query records', + detail: 'Field(s) present in the query do not have permission to be read', + }, + status: 'failure', + }, + }, + }; - const accessToken = await getAccessToken(getAccessTokenOpts); + await expect(() => mockResponseCallback(errorResponse)).rejects.toEqual(errorResponse); - expect(accessToken).toEqual('access_token brandnewaccesstoken'); - expect(createJWTAssertion as jest.Mock).toHaveBeenCalledWith( - logger, - 'privateKey', - 'privateKeyPassword', - { - audience: 'clientId', - issuer: 'clientId', - subject: 'userIdentifierValue', - keyId: 'jwtKeyId', - } - ); - expect(requestOAuthJWTToken as jest.Mock).toHaveBeenCalledWith( - 'https://dev23432523.service-now.com/oauth_token.do', - { clientId: 'clientId', clientSecret: 'clientSecret', assertion: 'newassertion' }, - logger, - configurationUtilities - ); - expect(connectorTokenClient.updateOrReplace).toHaveBeenCalledWith({ + expect(connectorTokenClient.deleteConnectorTokens).toHaveBeenCalledWith({ connectorId: '123', - token: { - id: '1', - connectorId: '123', - tokenType: 'access_token', - token: 'testtokenvalue', - createdAt, - expiresAt, - }, - newToken: 'access_token brandnewaccesstoken', - expiresInSec: 1000, - deleteExisting: false, }); }); - test('throws error if createJWTAssertion throws error', async () => { - connectorTokenClient.get.mockResolvedValueOnce({ - hasErrors: false, - connectorToken: null, - }); - (createJWTAssertion as jest.Mock).mockImplementationOnce(() => { - throw new Error('createJWTAssertion error!!'); + test('does not delete saved access token if not 4xx error response received', async () => { + getAxiosInstance({ + connectorId: '123', + logger, + configurationUtilities, + credentials: { + config: { + apiUrl: 'https://servicenow', + usesTableApi: true, + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'userIdentifierValue', + }, + secrets: { + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: null, + username: null, + password: null, + }, + }, + snServiceUrl: 'https://dev23432523.service-now.com', + connectorTokenClient, }); + expect(createAxiosInstanceMock).toHaveBeenCalledTimes(1); + expect(createAxiosInstanceMock).toHaveBeenCalledWith(); + expect(axiosInstanceMock.interceptors.request.use).toHaveBeenCalledTimes(1); + expect(axiosInstanceMock.interceptors.response.use).toHaveBeenCalledTimes(1); - await expect(getAccessToken(getAccessTokenOpts)).rejects.toThrowErrorMatchingInlineSnapshot( - `"createJWTAssertion error!!"` - ); - }); - - test('throws error if requestOAuthJWTToken throws error', async () => { - connectorTokenClient.get.mockResolvedValueOnce({ - hasErrors: false, - connectorToken: null, - }); - (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); - (requestOAuthJWTToken as jest.Mock).mockRejectedValueOnce( - new Error('requestOAuthJWTToken error!!') - ); + (getOAuthJwtAccessToken as jest.Mock).mockResolvedValueOnce('Bearer tokentokentoken'); - await expect(getAccessToken(getAccessTokenOpts)).rejects.toThrowErrorMatchingInlineSnapshot( - `"requestOAuthJWTToken error!!"` - ); - }); + const mockResponseCallback = (axiosInstanceMock.interceptors.response.use as jest.Mock).mock + .calls[0][1]; - test('logs warning if connectorTokenClient.updateOrReplace throws error', async () => { - connectorTokenClient.get.mockResolvedValueOnce({ - hasErrors: false, - connectorToken: null, - }); - (createJWTAssertion as jest.Mock).mockReturnValueOnce('newassertion'); - (requestOAuthJWTToken as jest.Mock).mockResolvedValueOnce({ - tokenType: 'access_token', - accessToken: 'brandnewaccesstoken', - expiresIn: 1000, - }); - connectorTokenClient.updateOrReplace.mockRejectedValueOnce( - new Error('updateOrReplace error') - ); + const errorResponse = { + response: { + status: 500, + statusText: 'Server error', + }, + }; - const accessToken = await getAccessToken(getAccessTokenOpts); + await expect(() => mockResponseCallback(errorResponse)).rejects.toEqual(errorResponse); - expect(accessToken).toEqual('access_token brandnewaccesstoken'); - expect(logger.warn).toHaveBeenCalledWith( - `Not able to update ServiceNow connector token for connectorId: 123 due to error: updateOrReplace error` - ); + expect(connectorTokenClient.deleteConnectorTokens).not.toHaveBeenCalled(); }); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts index 84d6741398bcebf..92fd13d86e608d3 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts @@ -5,7 +5,7 @@ * 2.0. */ -import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import { Logger } from '@kbn/core/server'; import { ExternalServiceCredentials, @@ -21,8 +21,7 @@ import { addTimeZoneToDate, getErrorMessage } from '../lib/axios_utils'; import * as i18n from './translations'; import { ActionsConfigurationUtilities } from '../../actions_config'; import { ConnectorTokenClientContract } from '../../types'; -import { createJWTAssertion } from '../lib/create_jwt_assertion'; -import { requestOAuthJWTToken } from '../lib/request_oauth_jwt_token'; +import { getOAuthJwtAccessToken } from '../lib/get_oauth_jwt_access_token'; export const prepareIncident = (useOldApi: boolean, incident: PartialIncident): PartialIncident => useOldApi @@ -83,7 +82,7 @@ export const throwIfSubActionIsNotSupported = ({ } }; -export interface GetAccessTokenAndAxiosInstanceOpts { +export interface GetAxiosInstanceOpts { connectorId: string; logger: Logger; configurationUtilities: ActionsConfigurationUtilities; @@ -99,7 +98,7 @@ export const getAxiosInstance = ({ credentials, snServiceUrl, connectorTokenClient, -}: GetAccessTokenAndAxiosInstanceOpts): AxiosInstance => { +}: GetAxiosInstanceOpts): AxiosInstance => { const { config, secrets } = credentials; const { isOAuth } = config as ServiceNowPublicConfigurationType; const { username, password } = secrets as ServiceNowSecretConfigurationType; @@ -114,97 +113,52 @@ export const getAxiosInstance = ({ axiosInstance = axios.create(); axiosInstance.interceptors.request.use( async (axiosConfig: AxiosRequestConfig) => { - const accessToken = await getAccessToken({ + const accessToken = await getOAuthJwtAccessToken({ connectorId, logger, configurationUtilities, credentials: { - config: config as ServiceNowPublicConfigurationType, - secrets, + config: { + clientId: config.clientId as string, + jwtKeyId: config.jwtKeyId as string, + userIdentifierValue: config.userIdentifierValue as string, + }, + secrets: { + clientSecret: secrets.clientSecret as string, + privateKey: secrets.privateKey as string, + privateKeyPassword: secrets.privateKeyPassword + ? (secrets.privateKeyPassword as string) + : null, + }, }, - snServiceUrl, + tokenUrl: `${snServiceUrl}/oauth_token.do`, connectorTokenClient, }); - axiosConfig.headers.Authorization = accessToken; + if (!accessToken) { + throw new Error(`Unable to retrieve access token for connectorId: ${connectorId}`); + } + axiosConfig.headers = { ...axiosConfig.headers, Authorization: accessToken }; return axiosConfig; }, (error) => { - Promise.reject(error); + return Promise.reject(error); + } + ); + axiosInstance.interceptors.response.use( + (response: AxiosResponse) => response, + async (error) => { + const statusCode = error?.response?.status; + + // Look for 4xx errors that indicate something is wrong with the request + // We don't know for sure that it is an access token issue but remove saved + // token just to be sure + if (statusCode >= 400 && statusCode < 500) { + await connectorTokenClient.deleteConnectorTokens({ connectorId }); + } + return Promise.reject(error); } ); } return axiosInstance; }; - -export const getAccessToken = async ({ - connectorId, - logger, - configurationUtilities, - credentials, - snServiceUrl, - connectorTokenClient, -}: GetAccessTokenAndAxiosInstanceOpts) => { - const { isOAuth, clientId, jwtKeyId, userIdentifierValue } = - credentials.config as ServiceNowPublicConfigurationType; - const { clientSecret, privateKey, privateKeyPassword } = - credentials.secrets as ServiceNowSecretConfigurationType; - - let accessToken: string; - - // Check if there is a token stored for this connector - const { connectorToken, hasErrors } = await connectorTokenClient.get({ connectorId }); - - if (connectorToken === null || Date.parse(connectorToken.expiresAt) <= Date.now()) { - // generate a new assertion - if ( - !isOAuth || - !clientId || - !clientSecret || - !jwtKeyId || - !privateKey || - !userIdentifierValue - ) { - return null; - } - - const assertion = createJWTAssertion(logger, privateKey, privateKeyPassword, { - audience: clientId, - issuer: clientId, - subject: userIdentifierValue, - keyId: jwtKeyId, - }); - - // request access token with jwt assertion - const tokenResult = await requestOAuthJWTToken( - `${snServiceUrl}/oauth_token.do`, - { - clientId, - clientSecret, - assertion, - }, - logger, - configurationUtilities - ); - accessToken = `${tokenResult.tokenType} ${tokenResult.accessToken}`; - - // try to update connector_token SO - try { - await connectorTokenClient.updateOrReplace({ - connectorId, - token: connectorToken, - newToken: accessToken, - expiresInSec: tokenResult.expiresIn, - deleteExisting: hasErrors, - }); - } catch (err) { - logger.warn( - `Not able to update ServiceNow connector token for connectorId: ${connectorId} due to error: ${err.message}` - ); - } - } else { - // use existing valid token - accessToken = connectorToken.token; - } - return accessToken; -}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.ts index a36b971635e3616..01d50fd623ff64f 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.ts @@ -158,7 +158,7 @@ export async function executor( const axiosInstance = axios.create(); - const result: Result = await promiseResult( + const result: Result> = await promiseResult( request({ axios: axiosInstance, method, diff --git a/x-pack/plugins/actions/server/lib/action_executor.test.ts b/x-pack/plugins/actions/server/lib/action_executor.test.ts index 093236c939aa12a..12898cea5a4828d 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.test.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.test.ts @@ -521,7 +521,7 @@ test('logs a warning when alert executor throws an error', async () => { executorMock.mockRejectedValue(new Error('this action execution is intended to fail')); await actionExecutor.execute(executeParams); expect(loggerMock.warn).toBeCalledWith( - 'action execution failure: test:1: action-1: an error occurred while running the action executor: this action execution is intended to fail' + 'action execution failure: test:1: action-1: an error occurred while running the action: this action execution is intended to fail' ); }); diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts index fe77b72f47aa369..b9ed252c6afc2df 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.ts @@ -19,6 +19,7 @@ import { validateConnector, } from './validate_with_schema'; import { + ActionType, ActionTypeExecutorResult, ActionTypeRegistryContract, GetServicesFunction, @@ -30,6 +31,7 @@ import { ActionsClient } from '../actions_client'; import { ActionExecutionSource } from './action_execution_source'; import { RelatedSavedObjects } from './related_saved_objects'; import { createActionEventLogRecordObject } from './create_action_event_log_record_object'; +import { ActionExecutionError, ActionExecutionErrorReason } from './errors/action_execution_error'; // 1,000,000 nanoseconds in 1 millisecond const Millis2Nanos = 1000 * 1000; @@ -157,24 +159,6 @@ export class ActionExecutor { } const actionType = actionTypeRegistry.get(actionTypeId); - let validatedParams: Record; - let validatedConfig: Record; - let validatedSecrets: Record; - try { - validatedParams = validateParams(actionType, params); - validatedConfig = validateConfig(actionType, config); - validatedSecrets = validateSecrets(actionType, secrets); - if (actionType.validate?.connector) { - validateConnector(actionType, { - config, - secrets, - }); - } - } catch (err) { - span?.setOutcome('failure'); - return { status: 'error', actionId, message: err.message, retry: false }; - } - const actionLabel = `${actionTypeId}:${actionId}: ${name}`; logger.debug(`executing action ${actionLabel}`); @@ -221,6 +205,14 @@ export class ActionExecutor { let rawResult: ActionTypeExecutorResult; try { + const { validatedParams, validatedConfig, validatedSecrets } = validateAction({ + actionId, + actionType, + params, + config, + secrets, + }); + rawResult = await actionType.executor({ actionId, services, @@ -231,14 +223,19 @@ export class ActionExecutor { taskInfo, }); } catch (err) { - rawResult = { - actionId, - status: 'error', - message: 'an error occurred while running the action executor', - serviceMessage: err.message, - retry: false, - }; + if (err.reason === ActionExecutionErrorReason.Validation) { + rawResult = err.result; + } else { + rawResult = { + actionId, + status: 'error', + message: 'an error occurred while running the action', + serviceMessage: err.message, + retry: false, + }; + } } + eventLogger.stopTiming(event); // allow null-ish return to indicate success @@ -411,3 +408,38 @@ function actionErrorToMessage(result: ActionTypeExecutorResult): string return message; } + +interface ValidateActionOpts { + actionId: string; + actionType: ActionType; + params: Record; + config: unknown; + secrets: unknown; +} + +function validateAction({ actionId, actionType, params, config, secrets }: ValidateActionOpts) { + let validatedParams: Record; + let validatedConfig: Record; + let validatedSecrets: Record; + + try { + validatedParams = validateParams(actionType, params); + validatedConfig = validateConfig(actionType, config); + validatedSecrets = validateSecrets(actionType, secrets); + if (actionType.validate?.connector) { + validateConnector(actionType, { + config, + secrets, + }); + } + + return { validatedParams, validatedConfig, validatedSecrets }; + } catch (err) { + throw new ActionExecutionError(err.message, ActionExecutionErrorReason.Validation, { + actionId, + status: 'error', + message: err.message, + retry: false, + }); + } +} diff --git a/x-pack/plugins/actions/server/lib/errors/action_execution_error.ts b/x-pack/plugins/actions/server/lib/errors/action_execution_error.ts new file mode 100644 index 000000000000000..ad43008ef8e20f5 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/errors/action_execution_error.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ActionTypeExecutorResult } from '../../types'; + +export enum ActionExecutionErrorReason { + Validation = 'validation', +} + +export class ActionExecutionError extends Error { + public readonly reason: ActionExecutionErrorReason; + public readonly result: ActionTypeExecutorResult; + + constructor( + message: string, + reason: ActionExecutionErrorReason, + result: ActionTypeExecutorResult + ) { + super(message); + this.reason = reason; + this.result = result; + } +} diff --git a/x-pack/plugins/actions/server/plugin.test.ts b/x-pack/plugins/actions/server/plugin.test.ts index d89a3c96b01b942..3f3895ec5b69f74 100644 --- a/x-pack/plugins/actions/server/plugin.test.ts +++ b/x-pack/plugins/actions/server/plugin.test.ts @@ -109,7 +109,7 @@ describe('Actions Plugin', () => { httpServerMock.createKibanaRequest(), httpServerMock.createResponseFactory() )) as unknown as ActionsApiRequestHandlerContext; - actionsContextHandler!.getActionsClient(); + expect(actionsContextHandler!.getActionsClient()).toBeDefined(); }); it('should throw error when ESO plugin is missing encryption key', async () => { diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 1fad2a6189693c5..c097b94a859503e 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -311,12 +311,13 @@ export class ActionsPlugin implements Plugin(), - this.licenseState, + defineRoutes({ + router: core.http.createRouter(), + licenseState: this.licenseState, + logger: this.logger, actionsConfigUtils, - this.usageCounter - ); + usageCounter: this.usageCounter, + }); // Cleanup failed execution task definition if (this.actionsConfig.cleanupFailedExecutionsTask.enabled) { diff --git a/x-pack/plugins/actions/server/routes/get_oauth_access_token.test.ts b/x-pack/plugins/actions/server/routes/get_oauth_access_token.test.ts new file mode 100644 index 000000000000000..888e87dbdf1f4fb --- /dev/null +++ b/x-pack/plugins/actions/server/routes/get_oauth_access_token.test.ts @@ -0,0 +1,227 @@ +/* + * 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 { getOAuthAccessToken } from './get_oauth_access_token'; +import { Logger } from '@kbn/core/server'; +import { httpServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { licenseStateMock } from '../lib/license_state.mock'; +import { mockHandlerArguments } from './legacy/_mock_handler_arguments'; +import { verifyAccessAndContext } from './verify_access_and_context'; +import { actionsConfigMock } from '../actions_config.mock'; +import { actionsClientMock } from '../actions_client.mock'; + +jest.mock('./verify_access_and_context', () => ({ + verifyAccessAndContext: jest.fn(), +})); + +const logger = loggingSystemMock.create().get() as jest.Mocked; +const configurationUtilities = actionsConfigMock.create(); + +beforeEach(() => { + jest.resetAllMocks(); + (verifyAccessAndContext as jest.Mock).mockImplementation((license, handler) => handler); +}); + +describe('getOAuthAccessToken', () => { + it('returns jwt access token for given jwt oauth config', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getOAuthAccessToken(router, licenseState, logger, configurationUtilities); + + const [config, handler] = router.post.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connector/_oauth_access_token"`); + + const actionsClient = actionsClientMock.create(); + actionsClient.getOAuthAccessToken.mockResolvedValueOnce({ + accessToken: 'Bearer jwttokentokentoken', + }); + + const requestBody = { + type: 'jwt', + options: { + tokenUrl: 'https://testurl.service-now.com/oauth_token.do', + config: { + clientId: 'abc', + jwtKeyId: 'def', + userIdentifierValue: 'userA', + }, + secrets: { + clientSecret: 'iamasecret', + privateKey: 'xyz', + }, + }, + }; + + const [context, req, res] = mockHandlerArguments( + { actionsClient }, + { + body: requestBody, + }, + ['ok'] + ); + + expect(await handler(context, req, res)).toMatchInlineSnapshot(` + Object { + "body": Object { + "accessToken": "Bearer jwttokentokentoken", + }, + } + `); + + expect(actionsClient.getOAuthAccessToken).toHaveBeenCalledTimes(1); + expect(actionsClient.getOAuthAccessToken.mock.calls[0]).toEqual([ + requestBody, + logger, + configurationUtilities, + ]); + + expect(res.ok).toHaveBeenCalledWith({ + body: { + accessToken: 'Bearer jwttokentokentoken', + }, + }); + }); + + it('returns client credentials access token for given client credentials oauth config', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getOAuthAccessToken(router, licenseState, logger, configurationUtilities); + + const [config, handler] = router.post.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connector/_oauth_access_token"`); + + const actionsClient = actionsClientMock.create(); + actionsClient.getOAuthAccessToken.mockResolvedValueOnce({ + accessToken: 'Bearer clienttokentokentoken', + }); + + const requestBody = { + type: 'client', + options: { + tokenUrl: 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + scope: 'https://graph.microsoft.com/.default', + config: { + clientId: 'abc', + tenantId: 'def', + }, + secrets: { + clientSecret: 'iamasecret', + }, + }, + }; + + const [context, req, res] = mockHandlerArguments( + { actionsClient }, + { + body: requestBody, + }, + ['ok'] + ); + + expect(await handler(context, req, res)).toMatchInlineSnapshot(` + Object { + "body": Object { + "accessToken": "Bearer clienttokentokentoken", + }, + } + `); + + expect(actionsClient.getOAuthAccessToken).toHaveBeenCalledTimes(1); + expect(actionsClient.getOAuthAccessToken.mock.calls[0]).toEqual([ + requestBody, + logger, + configurationUtilities, + ]); + + expect(res.ok).toHaveBeenCalledWith({ + body: { + accessToken: 'Bearer clienttokentokentoken', + }, + }); + }); + + it('ensures the license allows getting servicenow access token', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + getOAuthAccessToken(router, licenseState, logger, configurationUtilities); + + const [config, handler] = router.post.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connector/_oauth_access_token"`); + + const [context, req, res] = mockHandlerArguments( + {}, + { + body: { + type: 'jwt', + options: { + tokenUrl: 'https://testurl.service-now.com/oauth_token.do', + config: { + clientId: 'abc', + jwtKeyId: 'def', + userIdentifierValue: 'userA', + }, + secrets: { + clientSecret: 'iamasecret', + privateKey: 'xyz', + }, + }, + }, + }, + ['ok'] + ); + + await handler(context, req, res); + + expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function)); + }); + + it('ensures the license check prevents getting service now access token', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + (verifyAccessAndContext as jest.Mock).mockImplementation(() => async () => { + throw new Error('OMG'); + }); + + getOAuthAccessToken(router, licenseState, logger, configurationUtilities); + + const [config, handler] = router.post.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/internal/actions/connector/_oauth_access_token"`); + + const [context, req, res] = mockHandlerArguments( + {}, + { + body: { + type: 'client', + options: { + tokenUrl: 'https://login.microsoftonline.com/98765/oauth2/v2.0/token', + scope: 'https://graph.microsoft.com/.default', + config: { + clientId: 'abc', + tenantId: 'def', + }, + secrets: { + clientSecret: 'iamasecret', + }, + }, + }, + }, + ['ok'] + ); + + expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: OMG]`); + + expect(verifyAccessAndContext).toHaveBeenCalledWith(licenseState, expect.any(Function)); + }); +}); diff --git a/x-pack/plugins/actions/server/routes/get_oauth_access_token.ts b/x-pack/plugins/actions/server/routes/get_oauth_access_token.ts new file mode 100644 index 000000000000000..e1b612d321bcd9c --- /dev/null +++ b/x-pack/plugins/actions/server/routes/get_oauth_access_token.ts @@ -0,0 +1,81 @@ +/* + * 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, TypeOf } from '@kbn/config-schema'; +import { IRouter, Logger } from '@kbn/core/server'; +import { ILicenseState } from '../lib'; +import { INTERNAL_BASE_ACTION_API_PATH } from '../../common'; +import { ActionsRequestHandlerContext } from '../types'; +import { verifyAccessAndContext } from './verify_access_and_context'; +import { ActionsConfigurationUtilities } from '../actions_config'; + +const oauthJwtBodySchema = schema.object({ + tokenUrl: schema.string(), + config: schema.object({ + clientId: schema.string(), + jwtKeyId: schema.string(), + userIdentifierValue: schema.string(), + }), + secrets: schema.object({ + clientSecret: schema.string(), + privateKey: schema.string(), + privateKeyPassword: schema.maybe(schema.string()), + }), +}); + +export type OAuthJwtParams = TypeOf; + +const oauthClientCredentialsBodySchema = schema.object({ + tokenUrl: schema.string(), + scope: schema.string(), + config: schema.object({ + clientId: schema.string(), + tenantId: schema.string(), + }), + secrets: schema.object({ + clientSecret: schema.string(), + }), +}); + +export type OAuthClientCredentialsParams = TypeOf; + +const bodySchema = schema.object({ + type: schema.oneOf([schema.literal('jwt'), schema.literal('client')]), + options: schema.conditional( + schema.siblingRef('type'), + schema.literal('jwt'), + oauthJwtBodySchema, + oauthClientCredentialsBodySchema + ), +}); + +export type OAuthParams = TypeOf; + +export const getOAuthAccessToken = ( + router: IRouter, + licenseState: ILicenseState, + logger: Logger, + configurationUtilities: ActionsConfigurationUtilities +) => { + router.post( + { + path: `${INTERNAL_BASE_ACTION_API_PATH}/connector/_oauth_access_token`, + validate: { + body: bodySchema, + }, + }, + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const actionsClient = (await context.actions).getActionsClient(); + + return res.ok({ + body: await actionsClient.getOAuthAccessToken(req.body, logger, configurationUtilities), + }); + }) + ) + ); +}; diff --git a/x-pack/plugins/actions/server/routes/index.ts b/x-pack/plugins/actions/server/routes/index.ts index ab90141ae1c80f6..2822aa36689000e 100644 --- a/x-pack/plugins/actions/server/routes/index.ts +++ b/x-pack/plugins/actions/server/routes/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { IRouter } from '@kbn/core/server'; +import { IRouter, Logger } from '@kbn/core/server'; import { UsageCounter } from '@kbn/usage-collection-plugin/server'; import { ILicenseState } from '../lib'; import { ActionsRequestHandlerContext } from '../types'; @@ -17,15 +17,21 @@ import { getAllActionRoute } from './get_all'; import { connectorTypesRoute } from './connector_types'; import { updateActionRoute } from './update'; import { getWellKnownEmailServiceRoute } from './get_well_known_email_service'; +import { getOAuthAccessToken } from './get_oauth_access_token'; import { defineLegacyRoutes } from './legacy'; import { ActionsConfigurationUtilities } from '../actions_config'; -export function defineRoutes( - router: IRouter, - licenseState: ILicenseState, - actionsConfigUtils: ActionsConfigurationUtilities, - usageCounter?: UsageCounter -) { +export interface RouteOptions { + router: IRouter; + licenseState: ILicenseState; + logger: Logger; + actionsConfigUtils: ActionsConfigurationUtilities; + usageCounter?: UsageCounter; +} + +export function defineRoutes(opts: RouteOptions) { + const { router, licenseState, logger, actionsConfigUtils, usageCounter } = opts; + defineLegacyRoutes(router, licenseState, usageCounter); createActionRoute(router, licenseState); @@ -36,5 +42,6 @@ export function defineRoutes( connectorTypesRoute(router, licenseState); executeActionRoute(router, licenseState); + getOAuthAccessToken(router, licenseState, logger, actionsConfigUtils); getWellKnownEmailServiceRoute(router, licenseState); } diff --git a/x-pack/plugins/aiops/README.md b/x-pack/plugins/aiops/README.md new file mode 100755 index 000000000000000..9bfd64f9bf3a314 --- /dev/null +++ b/x-pack/plugins/aiops/README.md @@ -0,0 +1,9 @@ +# aiops + +The plugin provides APIs and components for AIOps features, including the “Explain log rate spikes” UI, maintained by the ML team. + +--- + +## Development + +See the [kibana contributing guide](https://github.com/elastic/kibana/blob/main/CONTRIBUTING.md) for instructions setting up your development environment. diff --git a/x-pack/plugins/aiops/common/api/example_stream.ts b/x-pack/plugins/aiops/common/api/example_stream.ts new file mode 100644 index 000000000000000..1210cccf5548794 --- /dev/null +++ b/x-pack/plugins/aiops/common/api/example_stream.ts @@ -0,0 +1,68 @@ +/* + * 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, TypeOf } from '@kbn/config-schema'; + +export const aiopsExampleStreamSchema = schema.object({ + /** Boolean flag to enable/disabling simulation of response errors. */ + simulateErrors: schema.maybe(schema.boolean()), + /** Maximum timeout between streaming messages. */ + timeout: schema.maybe(schema.number()), +}); + +export type AiopsExampleStreamSchema = TypeOf; + +export const API_ACTION_NAME = { + UPDATE_PROGRESS: 'update_progress', + ADD_TO_ENTITY: 'add_to_entity', + DELETE_ENTITY: 'delete_entity', +} as const; +export type ApiActionName = typeof API_ACTION_NAME[keyof typeof API_ACTION_NAME]; + +interface ApiActionUpdateProgress { + type: typeof API_ACTION_NAME.UPDATE_PROGRESS; + payload: number; +} + +export function updateProgressAction(payload: number): ApiActionUpdateProgress { + return { + type: API_ACTION_NAME.UPDATE_PROGRESS, + payload, + }; +} + +interface ApiActionAddToEntity { + type: typeof API_ACTION_NAME.ADD_TO_ENTITY; + payload: { + entity: string; + value: number; + }; +} + +export function addToEntityAction(entity: string, value: number): ApiActionAddToEntity { + return { + type: API_ACTION_NAME.ADD_TO_ENTITY, + payload: { + entity, + value, + }, + }; +} + +interface ApiActionDeleteEntity { + type: typeof API_ACTION_NAME.DELETE_ENTITY; + payload: string; +} + +export function deleteEntityAction(payload: string): ApiActionDeleteEntity { + return { + type: API_ACTION_NAME.DELETE_ENTITY, + payload, + }; +} + +export type ApiAction = ApiActionUpdateProgress | ApiActionAddToEntity | ApiActionDeleteEntity; diff --git a/x-pack/plugins/aiops/common/api/index.ts b/x-pack/plugins/aiops/common/api/index.ts new file mode 100644 index 000000000000000..da1e091d3fb5469 --- /dev/null +++ b/x-pack/plugins/aiops/common/api/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AiopsExampleStreamSchema } from './example_stream'; + +export const API_ENDPOINT = { + EXAMPLE_STREAM: '/internal/aiops/example_stream', + ANOTHER: '/internal/aiops/another', +} as const; +export type ApiEndpoint = typeof API_ENDPOINT[keyof typeof API_ENDPOINT]; + +export interface ApiEndpointOptions { + [API_ENDPOINT.EXAMPLE_STREAM]: AiopsExampleStreamSchema; + [API_ENDPOINT.ANOTHER]: { anotherOption: string }; +} diff --git a/x-pack/plugins/aiops/common/index.ts b/x-pack/plugins/aiops/common/index.ts new file mode 100755 index 000000000000000..0f4835d67ecc771 --- /dev/null +++ b/x-pack/plugins/aiops/common/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * PLUGIN_ID is used as a unique identifier for the aiops plugin + */ +export const PLUGIN_ID = 'aiops'; + +/** + * PLUGIN_NAME is used as the display name for the aiops plugin + */ +export const PLUGIN_NAME = 'AIOps'; + +/** + * This is an internal hard coded feature flag so we can easily turn on/off the + * "Explain log rate spikes UI" during development until the first release. + */ +export const AIOPS_ENABLED = true; diff --git a/x-pack/plugins/aiops/jest.config.js b/x-pack/plugins/aiops/jest.config.js new file mode 100644 index 000000000000000..4b92cb8dc86cbc3 --- /dev/null +++ b/x-pack/plugins/aiops/jest.config.js @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/x-pack/plugins/aiops'], + coverageDirectory: '/target/kibana-coverage/jest/x-pack/plugins/aiops', + coverageReporters: ['text', 'html'], + collectCoverageFrom: ['/x-pack/plugins/aiops/{common,public,server}/**/*.{js,ts,tsx}'], +}; diff --git a/x-pack/plugins/aiops/kibana.json b/x-pack/plugins/aiops/kibana.json new file mode 100755 index 000000000000000..b74a23bf2bc9e94 --- /dev/null +++ b/x-pack/plugins/aiops/kibana.json @@ -0,0 +1,16 @@ +{ + "id": "aiops", + "version": "1.0.0", + "kibanaVersion": "kibana", + "owner": { + "name": "Machine Learning UI", + "githubTeam": "ml-ui" + }, + "description": "AIOps plugin maintained by ML team.", + "server": true, + "ui": true, + "requiredPlugins": [], + "optionalPlugins": [], + "requiredBundles": ["kibanaReact"], + "extraPublicDirs": ["common"] +} diff --git a/x-pack/plugins/aiops/public/api/index.ts b/x-pack/plugins/aiops/public/api/index.ts new file mode 100644 index 000000000000000..6aa171df5286ce0 --- /dev/null +++ b/x-pack/plugins/aiops/public/api/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { lazyLoadModules } from '../lazy_load_bundle'; + +import type { ExplainLogRateSpikesSpec } from '../components/explain_log_rate_spikes'; + +export async function getExplainLogRateSpikesComponent(): Promise<() => ExplainLogRateSpikesSpec> { + const modules = await lazyLoadModules(); + return () => modules.ExplainLogRateSpikes; +} diff --git a/x-pack/plugins/aiops/public/components/app.tsx b/x-pack/plugins/aiops/public/components/app.tsx new file mode 100755 index 000000000000000..963253b154e279f --- /dev/null +++ b/x-pack/plugins/aiops/public/components/app.tsx @@ -0,0 +1,167 @@ +/* + * 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 React, { useEffect, useState } from 'react'; + +import { Chart, Settings, Axis, BarSeries, Position, ScaleType } from '@elastic/charts'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +import { + EuiBadge, + EuiButton, + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageContentHeader, + EuiProgress, + EuiSpacer, + EuiTitle, + EuiText, +} from '@elastic/eui'; + +import { getStatusMessage } from './get_status_message'; +import { initialState, resetStream, streamReducer } from './stream_reducer'; +import { useStreamFetchReducer } from './use_stream_fetch_reducer'; + +export const AiopsApp = () => { + const { notifications } = useKibana(); + + const [simulateErrors, setSimulateErrors] = useState(false); + + const { dispatch, start, cancel, data, isCancelled, isRunning } = useStreamFetchReducer( + '/internal/aiops/example_stream', + streamReducer, + initialState, + { simulateErrors } + ); + + const { errors, progress, entities } = data; + + const onClickHandler = async () => { + if (isRunning) { + cancel(); + } else { + dispatch(resetStream()); + start(); + } + }; + + useEffect(() => { + if (errors.length > 0) { + notifications.toasts.danger({ body: errors[errors.length - 1] }); + } + }, [errors, notifications.toasts]); + + const buttonLabel = isRunning + ? i18n.translate('xpack.aiops.stopbuttonText', { + defaultMessage: 'Stop development', + }) + : i18n.translate('xpack.aiops.startbuttonText', { + defaultMessage: 'Start development', + }); + + return ( + + + + + +

    + +

    +
    +
    + + + + + + {buttonLabel} + + + + + {progress}% + + + + + + + +
    + + + + + + { + return { + x, + y, + }; + }) + .sort((a, b) => b.y - a.y)} + /> + +
    +

    {getStatusMessage(isRunning, isCancelled, data.progress)}

    + setSimulateErrors(!simulateErrors)} + compressed + /> +
    +
    +
    +
    +
    + ); +}; diff --git a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes.tsx b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes.tsx new file mode 100644 index 000000000000000..21d7b39a2a1486b --- /dev/null +++ b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; + +import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { I18nProvider } from '@kbn/i18n-react'; + +import { getCoreStart } from '../kibana_services'; + +import { AiopsApp } from './app'; + +/** + * Spec used for lazy loading in the ML plugin + */ +export type ExplainLogRateSpikesSpec = typeof ExplainLogRateSpikes; + +export const ExplainLogRateSpikes: FC = () => { + const coreStart = getCoreStart(); + + return ( + + + + + + + + ); +}; diff --git a/x-pack/plugins/aiops/public/components/get_status_message.tsx b/x-pack/plugins/aiops/public/components/get_status_message.tsx new file mode 100644 index 000000000000000..e63748d03600a99 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/get_status_message.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export function getStatusMessage(isRunning: boolean, isCancelled: boolean, progress: number) { + if (!isRunning && !isCancelled && progress === 0) { + return 'Development did not start yet.'; + } else if (isRunning && !isCancelled) { + return 'Development is ongoing, the hype is real!'; + } else if (!isRunning && isCancelled) { + return 'Oh no, development got cancelled!'; + } else if (!isRunning && progress === 100) { + return 'Development clompeted, the release got out the door!'; + } + + // When the process stops but wasn't cancelled by the user and progress is not yet at 100%, + // this indicates there must have been a problem with the stream. + return 'Oh no, looks like there was a bug?!'; +} diff --git a/x-pack/plugins/aiops/public/components/stream_fetch.ts b/x-pack/plugins/aiops/public/components/stream_fetch.ts new file mode 100644 index 000000000000000..37d7c13dd3b55bc --- /dev/null +++ b/x-pack/plugins/aiops/public/components/stream_fetch.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type React from 'react'; + +import type { ApiEndpoint, ApiEndpointOptions } from '../../common/api'; + +export async function* streamFetch( + endpoint: E, + abortCtrl: React.MutableRefObject, + options: ApiEndpointOptions[ApiEndpoint], + basePath = '' +) { + const stream = await fetch(`${basePath}${endpoint}`, { + signal: abortCtrl.current.signal, + method: 'POST', + headers: { + // This refers to the format of the request body, + // not the response, which will be a uint8array Buffer. + 'Content-Type': 'application/json', + 'kbn-xsrf': 'stream', + }, + body: JSON.stringify(options), + }); + + if (stream.body !== null) { + // Note that Firefox 99 doesn't support `TextDecoderStream` yet. + // That's why we skip it here and use `TextDecoder` later to decode each chunk. + // Once Firefox supports it, we can use the following alternative: + // const reader = stream.body.pipeThrough(new TextDecoderStream()).getReader(); + const reader = stream.body.getReader(); + + const bufferBounce = 100; + let partial = ''; + let actionBuffer: A[] = []; + let lastCall = 0; + + while (true) { + try { + const { value: uint8array, done } = await reader.read(); + if (done) break; + + const value = new TextDecoder().decode(uint8array); + + const full = `${partial}${value}`; + const parts = full.split('\n'); + const last = parts.pop(); + + partial = last ?? ''; + + const actions = parts.map((p) => JSON.parse(p)); + actionBuffer.push(...actions); + + const now = Date.now(); + + if (now - lastCall >= bufferBounce && actionBuffer.length > 0) { + yield actionBuffer; + actionBuffer = []; + lastCall = now; + } + } catch (error) { + if (error.name !== 'AbortError') { + yield { type: 'error', payload: error.toString() }; + } + break; + } + } + + // The reader might finish with a partially filled actionBuffer so + // we need to clear it once more after the request is done. + if (actionBuffer.length > 0) { + yield actionBuffer; + actionBuffer.length = 0; + } + } +} diff --git a/x-pack/plugins/aiops/public/components/stream_reducer.ts b/x-pack/plugins/aiops/public/components/stream_reducer.ts new file mode 100644 index 000000000000000..3e68e139ceecaea --- /dev/null +++ b/x-pack/plugins/aiops/public/components/stream_reducer.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 { ApiAction, API_ACTION_NAME } from '../../common/api/example_stream'; + +export const UI_ACTION_NAME = { + ERROR: 'error', + RESET: 'reset', +} as const; +export type UiActionName = typeof UI_ACTION_NAME[keyof typeof UI_ACTION_NAME]; + +export interface StreamState { + errors: string[]; + progress: number; + entities: Record; +} +export const initialState: StreamState = { + errors: [], + progress: 0, + entities: {}, +}; + +interface UiActionError { + type: typeof UI_ACTION_NAME.ERROR; + payload: string; +} +interface UiActionResetStream { + type: typeof UI_ACTION_NAME.RESET; +} + +export function resetStream(): UiActionResetStream { + return { type: UI_ACTION_NAME.RESET }; +} + +type UiAction = UiActionResetStream | UiActionError; +export type ReducerAction = ApiAction | UiAction; +export function streamReducer( + state: StreamState, + action: ReducerAction | ReducerAction[] +): StreamState { + if (Array.isArray(action)) { + return action.reduce(streamReducer, state); + } + + switch (action.type) { + case API_ACTION_NAME.UPDATE_PROGRESS: + return { + ...state, + progress: action.payload, + }; + case API_ACTION_NAME.DELETE_ENTITY: + const deleteFromEntities = { ...state.entities }; + delete deleteFromEntities[action.payload]; + return { + ...state, + entities: deleteFromEntities, + }; + case API_ACTION_NAME.ADD_TO_ENTITY: + const addToEntities = { ...state.entities }; + if (addToEntities[action.payload.entity] === undefined) { + addToEntities[action.payload.entity] = action.payload.value; + } else { + addToEntities[action.payload.entity] += action.payload.value; + } + return { + ...state, + entities: addToEntities, + }; + case UI_ACTION_NAME.RESET: + return initialState; + case UI_ACTION_NAME.ERROR: + return { + ...state, + errors: [...state.errors, action.payload], + }; + default: + return { + ...state, + errors: [...state.errors, 'UNKNOWN_ACTION_ERROR'], + }; + } +} diff --git a/x-pack/plugins/aiops/public/components/use_stream_fetch_reducer.ts b/x-pack/plugins/aiops/public/components/use_stream_fetch_reducer.ts new file mode 100644 index 000000000000000..77ac09e0ff4297b --- /dev/null +++ b/x-pack/plugins/aiops/public/components/use_stream_fetch_reducer.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useReducer, useRef, useState, Reducer, ReducerAction, ReducerState } from 'react'; + +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +import type { ApiEndpoint, ApiEndpointOptions } from '../../common/api'; + +import { streamFetch } from './stream_fetch'; + +export const useStreamFetchReducer = , E = ApiEndpoint>( + endpoint: E, + reducer: R, + initialState: ReducerState, + options: ApiEndpointOptions[ApiEndpoint] +) => { + const kibana = useKibana(); + + const [isCancelled, setIsCancelled] = useState(false); + const [isRunning, setIsRunning] = useState(false); + + const [data, dispatch] = useReducer(reducer, initialState); + + const abortCtrl = useRef(new AbortController()); + + const start = async () => { + if (isRunning) { + throw new Error('Restart not supported yet'); + } + + setIsRunning(true); + setIsCancelled(false); + + abortCtrl.current = new AbortController(); + + for await (const actions of streamFetch( + endpoint, + abortCtrl, + options, + kibana.services.http?.basePath.get() + )) { + dispatch(actions as ReducerAction); + } + + setIsRunning(false); + }; + + const cancel = () => { + abortCtrl.current.abort(); + setIsCancelled(true); + setIsRunning(false); + }; + + return { + cancel, + data, + dispatch, + isCancelled, + isRunning, + start, + }; +}; diff --git a/x-pack/plugins/aiops/public/index.ts b/x-pack/plugins/aiops/public/index.ts new file mode 100755 index 000000000000000..30bcaf5afabdccb --- /dev/null +++ b/x-pack/plugins/aiops/public/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AiopsPlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, Kibana Platform `plugin()` initializer. +export function plugin() { + return new AiopsPlugin(); +} + +export type { AiopsPluginSetup, AiopsPluginStart } from './types'; + +export type { ExplainLogRateSpikesSpec } from './components/explain_log_rate_spikes'; diff --git a/x-pack/plugins/aiops/public/kibana_services.ts b/x-pack/plugins/aiops/public/kibana_services.ts new file mode 100644 index 000000000000000..9a43d2de5e5a180 --- /dev/null +++ b/x-pack/plugins/aiops/public/kibana_services.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreStart } from '@kbn/core/public'; +import { AppPluginStartDependencies } from './types'; + +let coreStart: CoreStart; +let pluginsStart: AppPluginStartDependencies; +export function setStartServices(core: CoreStart, plugins: AppPluginStartDependencies) { + coreStart = core; + pluginsStart = plugins; +} + +export const getCoreStart = () => coreStart; +export const getPluginsStart = () => pluginsStart; diff --git a/x-pack/plugins/aiops/public/lazy_load_bundle/index.ts b/x-pack/plugins/aiops/public/lazy_load_bundle/index.ts new file mode 100644 index 000000000000000..00723360801759b --- /dev/null +++ b/x-pack/plugins/aiops/public/lazy_load_bundle/index.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ExplainLogRateSpikesSpec } from '../components/explain_log_rate_spikes'; + +let loadModulesPromise: Promise; + +interface LazyLoadedModules { + ExplainLogRateSpikes: ExplainLogRateSpikesSpec; +} + +export async function lazyLoadModules(): Promise { + if (typeof loadModulesPromise !== 'undefined') { + return loadModulesPromise; + } + + loadModulesPromise = new Promise(async (resolve, reject) => { + try { + const lazyImports = await import('./lazy'); + resolve({ ...lazyImports }); + } catch (error) { + reject(error); + } + }); + return loadModulesPromise; +} diff --git a/x-pack/plugins/aiops/public/lazy_load_bundle/lazy/index.ts b/x-pack/plugins/aiops/public/lazy_load_bundle/lazy/index.ts new file mode 100644 index 000000000000000..967525de9bd6e97 --- /dev/null +++ b/x-pack/plugins/aiops/public/lazy_load_bundle/lazy/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { ExplainLogRateSpikesSpec } from '../../components/explain_log_rate_spikes'; +export { ExplainLogRateSpikes } from '../../components/explain_log_rate_spikes'; diff --git a/x-pack/plugins/aiops/public/plugin.ts b/x-pack/plugins/aiops/public/plugin.ts new file mode 100755 index 000000000000000..3c3cff39abb8034 --- /dev/null +++ b/x-pack/plugins/aiops/public/plugin.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; + +import { getExplainLogRateSpikesComponent } from './api'; +import { setStartServices } from './kibana_services'; +import { AiopsPluginSetup, AiopsPluginStart } from './types'; + +export class AiopsPlugin implements Plugin { + public setup(core: CoreSetup) {} + + public start(core: CoreStart) { + setStartServices(core, {}); + return { + getExplainLogRateSpikesComponent, + }; + } + + public stop() {} +} diff --git a/x-pack/plugins/aiops/public/types.ts b/x-pack/plugins/aiops/public/types.ts new file mode 100755 index 000000000000000..fae18dc1d310607 --- /dev/null +++ b/x-pack/plugins/aiops/public/types.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AiopsPlugin } from './plugin'; + +/** + * aiops plugin public setup contract + */ +export type AiopsPluginSetup = ReturnType; + +/** + * aiops plugin public start contract + */ +export type AiopsPluginStart = ReturnType; + +// eslint-disable-next-line +export type AppPluginStartDependencies = {}; diff --git a/x-pack/plugins/aiops/server/index.ts b/x-pack/plugins/aiops/server/index.ts new file mode 100755 index 000000000000000..8dca6eb397d5ee8 --- /dev/null +++ b/x-pack/plugins/aiops/server/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializerContext } from '@kbn/core/server'; +import { AiopsPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new AiopsPlugin(initializerContext); +} + +export type { AiopsPluginSetup, AiopsPluginStart } from './types'; diff --git a/x-pack/plugins/aiops/server/plugin.ts b/x-pack/plugins/aiops/server/plugin.ts new file mode 100755 index 000000000000000..c6b1b8b22a1873e --- /dev/null +++ b/x-pack/plugins/aiops/server/plugin.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializerContext, CoreSetup, CoreStart, Plugin, Logger } from '@kbn/core/server'; + +import { AiopsPluginSetup, AiopsPluginStart } from './types'; +import { defineRoutes } from './routes'; + +export class AiopsPlugin implements Plugin { + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public setup(core: CoreSetup) { + this.logger.debug('aiops: Setup'); + const router = core.http.createRouter(); + + // Register server side APIs + defineRoutes(router, this.logger); + + return {}; + } + + public start(core: CoreStart) { + this.logger.debug('aiops: Started'); + return {}; + } + + public stop() {} +} diff --git a/x-pack/plugins/aiops/server/routes/index.ts b/x-pack/plugins/aiops/server/routes/index.ts new file mode 100755 index 000000000000000..e87c27e2af81e37 --- /dev/null +++ b/x-pack/plugins/aiops/server/routes/index.ts @@ -0,0 +1,129 @@ +/* + * 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 { Readable } from 'stream'; + +import type { IRouter, Logger } from '@kbn/core/server'; + +import { AIOPS_ENABLED } from '../../common'; +import type { ApiAction } from '../../common/api/example_stream'; +import { + aiopsExampleStreamSchema, + updateProgressAction, + addToEntityAction, + deleteEntityAction, +} from '../../common/api/example_stream'; + +// We need this otherwise Kibana server will crash with a 'ERR_METHOD_NOT_IMPLEMENTED' error. +class ResponseStream extends Readable { + _read(): void {} +} + +const delimiter = '\n'; + +export function defineRoutes(router: IRouter, logger: Logger) { + if (AIOPS_ENABLED) { + router.post( + { + path: '/internal/aiops/example_stream', + validate: { + body: aiopsExampleStreamSchema, + }, + }, + async (context, request, response) => { + const maxTimeoutMs = request.body.timeout ?? 250; + const simulateError = request.body.simulateErrors ?? false; + + let shouldStop = false; + request.events.aborted$.subscribe(() => { + shouldStop = true; + }); + request.events.completed$.subscribe(() => { + shouldStop = true; + }); + + const stream = new ResponseStream(); + + function streamPush(d: ApiAction) { + try { + const line = JSON.stringify(d); + stream.push(`${line}${delimiter}`); + } catch (error) { + logger.error('Could not serialize or stream a message.'); + logger.error(error); + } + } + + const entities = [ + 'kimchy', + 's1monw', + 'martijnvg', + 'jasontedor', + 'nik9000', + 'javanna', + 'rjernst', + 'jrodewig', + ]; + + const actions = [...Array(19).fill('add'), 'delete']; + + if (simulateError) { + actions.push('server-only-error'); + actions.push('server-to-client-error'); + actions.push('client-error'); + } + + let progress = 0; + + async function pushStreamUpdate() { + setTimeout(() => { + try { + progress++; + + if (progress > 100 || shouldStop) { + stream.push(null); + return; + } + + streamPush(updateProgressAction(progress)); + + const randomEntity = entities[Math.floor(Math.random() * entities.length)]; + const randomAction = actions[Math.floor(Math.random() * actions.length)]; + + if (randomAction === 'add') { + const randomCommits = Math.floor(Math.random() * 100); + streamPush(addToEntityAction(randomEntity, randomCommits)); + } else if (randomAction === 'delete') { + streamPush(deleteEntityAction(randomEntity)); + } else if (randomAction === 'server-to-client-error') { + // Throw an error. It should not crash Kibana! + throw new Error('There was a (simulated) server side error!'); + } else if (randomAction === 'client-error') { + // Return not properly encoded JSON to the client. + stream.push(`{body:'Not valid JSON${delimiter}`); + } + + pushStreamUpdate(); + } catch (error) { + stream.push( + `${JSON.stringify({ type: 'error', payload: error.toString() })}${delimiter}` + ); + stream.push(null); + } + }, Math.floor(Math.random() * maxTimeoutMs)); + } + + // do not call this using `await` so it will run asynchronously while we return the stream already. + pushStreamUpdate(); + + return response.ok({ + body: stream, + }); + } + ); + } +} diff --git a/x-pack/plugins/aiops/server/types.ts b/x-pack/plugins/aiops/server/types.ts new file mode 100755 index 000000000000000..526e7280e949514 --- /dev/null +++ b/x-pack/plugins/aiops/server/types.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * aiops plugin server setup contract + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface AiopsPluginSetup {} + +/** + * aiops plugin server start contract + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface AiopsPluginStart {} diff --git a/x-pack/plugins/aiops/tsconfig.json b/x-pack/plugins/aiops/tsconfig.json new file mode 100644 index 000000000000000..2545c0e21ed0360 --- /dev/null +++ b/x-pack/plugins/aiops/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "../../../typings/**/*", + "common/**/*", + "public/**/*", + "scripts/**/*", + "server/**/*", + "types/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../../../src/plugins/custom_integrations/tsconfig.json" }, + { "path": "../../../src/plugins/navigation/tsconfig.json" }, + { "path": "../../../src/plugins/unified_search/tsconfig.json" }, + { "path": "../security/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/alerting/common/rule.ts b/x-pack/plugins/alerting/common/rule.ts index c8f282bf695d796..4509a004c6e585a 100644 --- a/x-pack/plugins/alerting/common/rule.ts +++ b/x-pack/plugins/alerting/common/rule.ts @@ -75,6 +75,7 @@ export interface RuleAggregations { ruleEnabledStatus: { enabled: number; disabled: number }; ruleMutedStatus: { muted: number; unmuted: number }; ruleSnoozedStatus: { snoozed: number }; + ruleTags: string[]; } export interface MappedParamsProperties { diff --git a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts index 26aa7a706db0a2a..bfa20cb2d00fe1c 100644 --- a/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts +++ b/x-pack/plugins/alerting/server/authorization/alerting_authorization.ts @@ -47,6 +47,7 @@ export enum WriteOperations { MuteAlert = 'muteAlert', UnmuteAlert = 'unmuteAlert', Snooze = 'snooze', + BulkEdit = 'bulkEdit', Unsnooze = 'unsnooze', } diff --git a/x-pack/plugins/alerting/server/index.ts b/x-pack/plugins/alerting/server/index.ts index 1ff36f483a211a7..93972dcb8df9d19 100644 --- a/x-pack/plugins/alerting/server/index.ts +++ b/x-pack/plugins/alerting/server/index.ts @@ -31,7 +31,14 @@ export type { } from './types'; export { DEFAULT_MAX_EPHEMERAL_ACTIONS_PER_ALERT } from './config'; export type { PluginSetupContract, PluginStartContract } from './plugin'; -export type { FindResult } from './rules_client'; +export type { + FindResult, + BulkEditOperation, + BulkEditError, + BulkEditOptions, + BulkEditOptionsFilter, + BulkEditOptionsIds, +} from './rules_client'; export type { PublicAlert as Alert } from './alert'; export { parseDuration } from './lib'; export { getEsErrorMessage } from './lib/errors'; diff --git a/x-pack/plugins/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.test.ts b/x-pack/plugins/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.test.ts new file mode 100644 index 000000000000000..34545292bf5f84d --- /dev/null +++ b/x-pack/plugins/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.test.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 { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { bulkMarkApiKeysForInvalidation } from './bulk_mark_api_keys_for_invalidation'; + +describe('bulkMarkApiKeysForInvalidation', () => { + test('should call savedObjectsClient bulkCreate with the proper params', async () => { + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [] }); + + await bulkMarkApiKeysForInvalidation( + { apiKeys: [Buffer.from('123').toString('base64'), Buffer.from('456').toString('base64')] }, + loggingSystemMock.create().get(), + unsecuredSavedObjectsClient + ); + + const bulkCreateCallMock = unsecuredSavedObjectsClient.bulkCreate.mock.calls[0]; + const savedObjects = bulkCreateCallMock[0]; + + expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); + expect(bulkCreateCallMock).toHaveLength(1); + + expect(savedObjects).toHaveLength(2); + expect(savedObjects[0]).toHaveProperty('type', 'api_key_pending_invalidation'); + expect(savedObjects[0]).toHaveProperty('attributes.apiKeyId', '123'); + expect(savedObjects[0]).toHaveProperty('attributes.createdAt', expect.any(String)); + expect(savedObjects[1]).toHaveProperty('type', 'api_key_pending_invalidation'); + expect(savedObjects[1]).toHaveProperty('attributes.apiKeyId', '456'); + expect(savedObjects[1]).toHaveProperty('attributes.createdAt', expect.any(String)); + }); + + test('should log the proper error when savedObjectsClient create failed', async () => { + const logger = loggingSystemMock.create().get(); + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + unsecuredSavedObjectsClient.bulkCreate.mockRejectedValueOnce(new Error('Fail')); + await bulkMarkApiKeysForInvalidation( + { apiKeys: [Buffer.from('123').toString('base64'), Buffer.from('456').toString('base64')] }, + logger, + unsecuredSavedObjectsClient + ); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to bulk mark list of API keys ["MTIz", "NDU2"] for invalidation: Fail' + ); + }); + + test('should not call savedObjectsClient bulkCreate if list of apiKeys empty', async () => { + const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); + unsecuredSavedObjectsClient.bulkCreate.mockResolvedValueOnce({ saved_objects: [] }); + + await bulkMarkApiKeysForInvalidation( + { apiKeys: [] }, + loggingSystemMock.create().get(), + unsecuredSavedObjectsClient + ); + + expect(unsecuredSavedObjectsClient.bulkCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.ts b/x-pack/plugins/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.ts new file mode 100644 index 000000000000000..8999d12772f0356 --- /dev/null +++ b/x-pack/plugins/alerting/server/invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Logger, SavedObjectsClientContract } from '@kbn/core/server'; + +export const bulkMarkApiKeysForInvalidation = async ( + { apiKeys }: { apiKeys: string[] }, + logger: Logger, + savedObjectsClient: SavedObjectsClientContract +): Promise => { + if (apiKeys.length === 0) { + return; + } + + try { + const apiKeyIds = apiKeys.map( + (apiKey) => Buffer.from(apiKey, 'base64').toString().split(':')[0] + ); + await savedObjectsClient.bulkCreate( + apiKeyIds.map((apiKeyId) => ({ + attributes: { + apiKeyId, + createdAt: new Date().toISOString(), + }, + type: 'api_key_pending_invalidation', + })) + ); + } catch (e) { + logger.error( + `Failed to bulk mark list of API keys [${apiKeys + .map((key) => `"${key}"`) + .join(', ')}] for invalidation: ${e.message}` + ); + } +}; diff --git a/x-pack/plugins/alerting/server/invalidate_pending_api_keys/mark_api_key_for_invalidation.test.ts b/x-pack/plugins/alerting/server/invalidate_pending_api_keys/mark_api_key_for_invalidation.test.ts deleted file mode 100644 index 85ab0b579e762c4..000000000000000 --- a/x-pack/plugins/alerting/server/invalidate_pending_api_keys/mark_api_key_for_invalidation.test.ts +++ /dev/null @@ -1,48 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; -import { markApiKeyForInvalidation } from './mark_api_key_for_invalidation'; - -describe('markApiKeyForInvalidation', () => { - test('should call savedObjectsClient create with the proper params', async () => { - const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); - await markApiKeyForInvalidation( - { apiKey: Buffer.from('123:abc').toString('base64') }, - loggingSystemMock.create().get(), - unsecuredSavedObjectsClient - ); - expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); - expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(2); - expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual( - 'api_key_pending_invalidation' - ); - }); - - test('should log the proper error when savedObjectsClient create failed', async () => { - const logger = loggingSystemMock.create().get(); - const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail')); - await markApiKeyForInvalidation( - { apiKey: Buffer.from('123').toString('base64') }, - logger, - unsecuredSavedObjectsClient - ); - expect(logger.error).toHaveBeenCalledWith( - 'Failed to mark for API key [id="MTIz"] for invalidation: Fail' - ); - }); -}); diff --git a/x-pack/plugins/alerting/server/invalidate_pending_api_keys/mark_api_key_for_invalidation.ts b/x-pack/plugins/alerting/server/invalidate_pending_api_keys/mark_api_key_for_invalidation.ts deleted file mode 100644 index 16bc2cf101102b1..000000000000000 --- a/x-pack/plugins/alerting/server/invalidate_pending_api_keys/mark_api_key_for_invalidation.ts +++ /dev/null @@ -1,27 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Logger, SavedObjectsClientContract } from '@kbn/core/server'; - -export const markApiKeyForInvalidation = async ( - { apiKey }: { apiKey: string | null }, - logger: Logger, - savedObjectsClient: SavedObjectsClientContract -): Promise => { - if (!apiKey) { - return; - } - try { - const apiKeyId = Buffer.from(apiKey, 'base64').toString().split(':')[0]; - await savedObjectsClient.create('api_key_pending_invalidation', { - apiKeyId, - createdAt: new Date().toISOString(), - }); - } catch (e) { - logger.error(`Failed to mark for API key [id="${apiKey}"] for invalidation: ${e.message}`); - } -}; diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.mock.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.mock.ts new file mode 100644 index 000000000000000..bcbb72518e4e479 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.mock.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PublicMethodsOf } from '@kbn/utility-types'; +import { AlertingEventLogger } from './alerting_event_logger'; + +const createAlertingEventLoggerMock = () => { + const mock: jest.Mocked> = { + initialize: jest.fn(), + start: jest.fn(), + getEvent: jest.fn(), + getStartAndDuration: jest.fn(), + setRuleName: jest.fn(), + setExecutionSucceeded: jest.fn(), + setExecutionFailed: jest.fn(), + logTimeout: jest.fn(), + logAlert: jest.fn(), + logAction: jest.fn(), + done: jest.fn(), + }; + return mock; +}; + +export const alertingEventLoggerMock = { + create: createAlertingEventLoggerMock, +}; diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts new file mode 100644 index 000000000000000..c980d61bb08fe14 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts @@ -0,0 +1,1115 @@ +/* + * 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 { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock'; +import { IEvent, SAVED_OBJECT_REL_PRIMARY } from '@kbn/event-log-plugin/server'; +import { + AlertingEventLogger, + RuleContextOpts, + initializeExecuteRecord, + createExecuteStartRecord, + createExecuteTimeoutRecord, + createAlertRecord, + createActionExecuteRecord, + updateEvent, +} from './alerting_event_logger'; +import { UntypedNormalizedRuleType } from '../../rule_type_registry'; +import { + ActionsCompletion, + RecoveredActionGroup, + RuleExecutionStatusErrorReasons, + RuleExecutionStatusWarningReasons, +} from '../../types'; +import { RuleRunMetrics } from '../rule_run_metrics_store'; +import { EVENT_LOG_ACTIONS } from '../../plugin'; + +const mockNow = '2020-01-01T02:00:00.000Z'; +const eventLogger = eventLoggerMock.create(); + +const ruleType: jest.Mocked = { + id: 'test', + name: 'My test rule', + actionGroups: [{ id: 'default', name: 'Default' }, RecoveredActionGroup], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: RecoveredActionGroup, + executor: jest.fn(), + producer: 'alerts', + ruleTaskTimeout: '1m', +}; + +const context: RuleContextOpts = { + ruleId: '123', + ruleType, + consumer: 'test-consumer', + spaceId: 'test-space', + executionId: 'abcd-efgh-ijklmnop', + taskScheduledAt: new Date('2020-01-01T00:00:00.000Z'), +}; + +const contextWithScheduleDelay = { ...context, taskScheduleDelay: 7200000 }; +const contextWithName = { ...contextWithScheduleDelay, ruleName: 'my-super-cool-rule' }; + +const alert = { + action: EVENT_LOG_ACTIONS.activeInstance, + id: 'aaabbb', + message: `.test-rule-type:123: 'my rule' active alert: 'aaabbb' in actionGroup: 'aGroup'; actionSubGroup: 'bSubGroup'`, + group: 'aGroup', + subgroup: 'bSubgroup', + state: { + start: '2020-01-01T02:00:00.000Z', + end: '2020-01-01T03:00:00.000Z', + duration: '2343252346', + }, +}; + +const action = { + id: 'abc', + typeId: '.email', + alertId: '123', + alertGroup: 'aGroup', + alertSubgroup: 'bSubgroup', +}; + +describe('AlertingEventLogger', () => { + let alertingEventLogger: AlertingEventLogger; + + beforeAll(() => { + jest.useFakeTimers('modern'); + jest.setSystemTime(new Date(mockNow)); + }); + + beforeEach(() => { + jest.resetAllMocks(); + alertingEventLogger = new AlertingEventLogger(eventLogger); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + describe('initialize()', () => { + test('initialization should succeed if alertingEventLogger has not been initialized', () => { + expect(() => alertingEventLogger.initialize(context)).not.toThrow(); + }); + + test('initialization should fail if alertingEventLogger has already been initialized', () => { + alertingEventLogger.initialize(context); + expect(() => alertingEventLogger.initialize(context)).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger already initialized"` + ); + }); + }); + + describe('start()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => alertingEventLogger.start()).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is null', () => { + alertingEventLogger.initialize(null as unknown as RuleContextOpts); + expect(() => alertingEventLogger.start()).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is undefined', () => { + alertingEventLogger.initialize(undefined as unknown as RuleContextOpts); + expect(() => alertingEventLogger.start()).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should call eventLogger "startTiming" and "logEvent"', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + + expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); + expect(eventLogger.logEvent).toHaveBeenCalledTimes(1); + + expect(eventLogger.startTiming).toHaveBeenCalledWith( + initializeExecuteRecord(contextWithScheduleDelay), + new Date(mockNow) + ); + expect(eventLogger.logEvent).toHaveBeenCalledWith( + createExecuteStartRecord(contextWithScheduleDelay, new Date(mockNow)) + ); + }); + + test('should initialize the "execute" event', () => { + mockEventLoggerStartTiming(); + + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + expect(alertingEventLogger.getEvent()).toEqual({ + ...event, + event: { + ...event.event, + start: new Date(mockNow).toISOString(), + }, + }); + }); + }); + + describe('setRuleName()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => alertingEventLogger.setRuleName('')).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if event is null', () => { + alertingEventLogger.initialize(context); + expect(() => alertingEventLogger.setRuleName('')).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should update event with rule name correctly', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.setRuleName('my-super-cool-rule'); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + expect(alertingEventLogger.getEvent()).toEqual({ + ...event, + rule: { + ...event.rule, + name: 'my-super-cool-rule', + }, + }); + }); + }); + + describe('setExecutionSucceeded()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => + alertingEventLogger.setExecutionSucceeded('') + ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger not initialized"`); + }); + + test('should throw error if event is null', () => { + alertingEventLogger.initialize(context); + expect(() => + alertingEventLogger.setExecutionSucceeded('') + ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger not initialized"`); + }); + + test('should update execute event correctly', () => { + mockEventLoggerStartTiming(); + + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.setRuleName('my-super-cool-rule'); + alertingEventLogger.setExecutionSucceeded('success!'); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + expect(alertingEventLogger.getEvent()).toEqual({ + ...event, + event: { + ...event.event, + start: new Date(mockNow).toISOString(), + outcome: 'success', + }, + rule: { + ...event.rule, + name: 'my-super-cool-rule', + }, + message: 'success!', + }); + }); + }); + + describe('setExecutionFailed()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => + alertingEventLogger.setExecutionFailed('', '') + ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger not initialized"`); + }); + + test('should throw error if event is null', () => { + alertingEventLogger.initialize(context); + expect(() => + alertingEventLogger.setExecutionFailed('', '') + ).toThrowErrorMatchingInlineSnapshot(`"AlertingEventLogger not initialized"`); + }); + + test('should update execute event correctly', () => { + mockEventLoggerStartTiming(); + + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.setExecutionFailed('rule failed!', 'something went wrong!'); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + expect(alertingEventLogger.getEvent()).toEqual({ + ...event, + event: { + ...event.event, + start: new Date(mockNow).toISOString(), + outcome: 'failure', + }, + error: { + message: 'something went wrong!', + }, + message: 'rule failed!', + }); + }); + }); + + describe('logTimeout()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => alertingEventLogger.logTimeout()).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is null', () => { + alertingEventLogger.initialize(null as unknown as RuleContextOpts); + expect(() => alertingEventLogger.logTimeout()).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is undefined', () => { + alertingEventLogger.initialize(undefined as unknown as RuleContextOpts); + expect(() => alertingEventLogger.logTimeout()).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should log timeout event', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.logTimeout(); + + const event = createExecuteTimeoutRecord(contextWithName); + + expect(eventLogger.logEvent).toHaveBeenCalledWith(event); + }); + }); + + describe('logAlert()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => alertingEventLogger.logAlert(alert)).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is null', () => { + alertingEventLogger.initialize(null as unknown as RuleContextOpts); + expect(() => alertingEventLogger.logAlert(alert)).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is undefined', () => { + alertingEventLogger.initialize(undefined as unknown as RuleContextOpts); + expect(() => alertingEventLogger.logAlert(alert)).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should log timeout event', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.logAlert(alert); + + const event = createAlertRecord(contextWithName, alert); + + expect(eventLogger.logEvent).toHaveBeenCalledWith(event); + }); + }); + + describe('logAction()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => alertingEventLogger.logAction(action)).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is null', () => { + alertingEventLogger.initialize(null as unknown as RuleContextOpts); + expect(() => alertingEventLogger.logAction(action)).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is undefined', () => { + alertingEventLogger.initialize(undefined as unknown as RuleContextOpts); + expect(() => alertingEventLogger.logAction(action)).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should log timeout event', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.logAction(action); + + const event = createActionExecuteRecord(contextWithName, action); + + expect(eventLogger.logEvent).toHaveBeenCalledWith(event); + }); + }); + + describe('done()', () => { + test('should throw error if alertingEventLogger has not been initialized', () => { + expect(() => alertingEventLogger.done({})).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is null', () => { + alertingEventLogger.initialize(null as unknown as RuleContextOpts); + expect(() => alertingEventLogger.done({})).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if alertingEventLogger rule context is undefined', () => { + alertingEventLogger.initialize(undefined as unknown as RuleContextOpts); + expect(() => alertingEventLogger.done({})).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should throw error if event is null', () => { + alertingEventLogger.initialize(context); + expect(() => alertingEventLogger.done({})).toThrowErrorMatchingInlineSnapshot( + `"AlertingEventLogger not initialized"` + ); + }); + + test('should log event if no status or metrics are provided', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({}); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + + expect(eventLogger.logEvent).toHaveBeenCalledWith(event); + }); + + test('should set fields from execution status if provided', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + status: { lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), status: 'active' }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + const loggedEvent = { + ...event, + kibana: { + ...event?.kibana, + alerting: { + status: 'active', + }, + }, + }; + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields from execution status if execution status is error', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + status: { + lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), + status: 'error', + error: { + reason: RuleExecutionStatusErrorReasons.Execute, + message: 'something went wrong', + }, + }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + const loggedEvent = { + ...event, + event: { + ...event?.event, + outcome: 'failure', + reason: RuleExecutionStatusErrorReasons.Execute, + }, + error: { + message: 'something went wrong', + }, + kibana: { + ...event?.kibana, + alerting: { + status: 'error', + }, + }, + message: 'test:123: execution failed', + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields from execution status if execution status is error and uses "unknown" if no reason is provided', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + status: { + lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), + status: 'error', + error: { + reason: undefined as unknown as RuleExecutionStatusErrorReasons, + message: 'something went wrong', + }, + }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + const loggedEvent = { + ...event, + event: { + ...event?.event, + outcome: 'failure', + reason: 'unknown', + }, + error: { + message: 'something went wrong', + }, + kibana: { + ...event?.kibana, + alerting: { + status: 'error', + }, + }, + message: 'test:123: execution failed', + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields from execution status if execution status is error and does not overwrite existing error message', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + status: { + lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), + status: 'error', + error: { + reason: undefined as unknown as RuleExecutionStatusErrorReasons, + message: 'something went wrong', + }, + }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + alertingEventLogger.setExecutionFailed( + 'i am an existing error message', + 'i am an existing error message!' + ); + const loggedEvent = { + ...event, + event: { + ...event?.event, + outcome: 'failure', + reason: 'unknown', + }, + error: { + message: 'i am an existing error message!', + }, + kibana: { + ...event?.kibana, + alerting: { + status: 'error', + }, + }, + message: 'i am an existing error message', + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields from execution status if execution status is warning', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + status: { + lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), + status: 'warning', + warning: { + reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: 'something funky happened', + }, + }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + const loggedEvent = { + ...event, + event: { + ...event?.event, + reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + }, + kibana: { + ...event?.kibana, + alerting: { + status: 'warning', + }, + }, + message: 'something funky happened', + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields from execution status if execution status is warning and uses "unknown" if no reason is provided', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + status: { + lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), + status: 'warning', + warning: { + reason: undefined as unknown as RuleExecutionStatusWarningReasons, + message: 'something funky happened', + }, + }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + const loggedEvent = { + ...event, + event: { + ...event?.event, + reason: 'unknown', + }, + kibana: { + ...event?.kibana, + alerting: { + status: 'warning', + }, + }, + message: 'something funky happened', + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields from execution status if execution status is warning and uses existing message if no message is provided', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + status: { + lastExecutionDate: new Date('2022-05-05T15:59:54.480Z'), + status: 'warning', + warning: { + reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, + message: undefined as unknown as string, + }, + }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + alertingEventLogger.setExecutionSucceeded('success!'); + const loggedEvent = { + ...event, + event: { + ...event?.event, + reason: 'maxExecutableActions', + outcome: 'success', + }, + kibana: { + ...event?.kibana, + alerting: { + status: 'warning', + }, + }, + message: 'success!', + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields from execution metrics if provided', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + metrics: { + numberOfTriggeredActions: 1, + numberOfGeneratedActions: 2, + numberOfActiveAlerts: 3, + numberOfNewAlerts: 4, + numberOfRecoveredAlerts: 5, + numSearches: 6, + esSearchDurationMs: 3300, + totalSearchDurationMs: 10333, + triggeredActionsStatus: ActionsCompletion.COMPLETE, + }, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + const loggedEvent = { + ...event, + kibana: { + ...event.kibana, + alert: { + ...event.kibana?.alert, + rule: { + ...event.kibana?.alert?.rule, + execution: { + ...event.kibana?.alert?.rule?.execution, + metrics: { + number_of_triggered_actions: 1, + number_of_generated_actions: 2, + number_of_active_alerts: 3, + number_of_new_alerts: 4, + number_of_recovered_alerts: 5, + total_number_of_alerts: 8, + number_of_searches: 6, + es_search_duration_ms: 3300, + total_search_duration_ms: 10333, + }, + }, + }, + }, + }, + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + + test('should set fields to 0 execution metrics are provided but undefined', () => { + alertingEventLogger.initialize(context); + alertingEventLogger.start(); + alertingEventLogger.done({ + metrics: {} as unknown as RuleRunMetrics, + }); + + const event = initializeExecuteRecord(contextWithScheduleDelay); + const loggedEvent = { + ...event, + kibana: { + ...event.kibana, + alert: { + ...event.kibana?.alert, + rule: { + ...event.kibana?.alert?.rule, + execution: { + ...event.kibana?.alert?.rule?.execution, + metrics: { + number_of_triggered_actions: 0, + number_of_generated_actions: 0, + number_of_active_alerts: 0, + number_of_new_alerts: 0, + number_of_recovered_alerts: 0, + total_number_of_alerts: 0, + number_of_searches: 0, + es_search_duration_ms: 0, + total_search_duration_ms: 0, + }, + }, + }, + }, + }, + }; + + expect(alertingEventLogger.getEvent()).toEqual(loggedEvent); + expect(eventLogger.logEvent).toHaveBeenCalledWith(loggedEvent); + }); + }); +}); + +describe('createExecuteStartRecord', () => { + test('should create execute-start record', () => { + const executeRecord = initializeExecuteRecord(contextWithScheduleDelay); + const record = createExecuteStartRecord(contextWithScheduleDelay); + + expect(record).toEqual({ + ...executeRecord, + event: { + ...executeRecord.event, + action: 'execute-start', + }, + message: `rule execution start: "123"`, + }); + }); + + test('should create execute-start record with given start time', () => { + const executeRecord = initializeExecuteRecord(contextWithScheduleDelay); + const record = createExecuteStartRecord( + contextWithScheduleDelay, + new Date('2022-01-01T02:00:00.000Z') + ); + + expect(record).toEqual({ + ...executeRecord, + event: { + ...executeRecord.event, + action: 'execute-start', + start: '2022-01-01T02:00:00.000Z', + }, + message: `rule execution start: "123"`, + }); + }); +}); + +describe('initializeExecuteRecord', () => { + test('should populate initial set of fields in event log record', () => { + const record = initializeExecuteRecord(contextWithScheduleDelay); + + expect(record.event).toBeDefined(); + expect(record.kibana).toBeDefined(); + expect(record.kibana?.alert).toBeDefined(); + expect(record.kibana?.alert?.rule).toBeDefined(); + expect(record.kibana?.alert?.rule?.execution).toBeDefined(); + expect(record.kibana?.saved_objects).toBeDefined(); + expect(record.kibana?.space_ids).toBeDefined(); + expect(record.kibana?.task).toBeDefined(); + expect(record.rule).toBeDefined(); + + // these fields should be explicitly set + expect(record.event?.action).toEqual('execute'); + expect(record.event?.kind).toEqual('alert'); + expect(record.event?.category).toEqual([contextWithScheduleDelay.ruleType.producer]); + expect(record.kibana?.alert?.rule?.rule_type_id).toEqual(contextWithScheduleDelay.ruleType.id); + expect(record.kibana?.alert?.rule?.consumer).toEqual(contextWithScheduleDelay.consumer); + expect(record.kibana?.alert?.rule?.execution?.uuid).toEqual( + contextWithScheduleDelay.executionId + ); + expect(record.kibana?.saved_objects).toEqual([ + { + id: contextWithScheduleDelay.ruleId, + type: 'alert', + type_id: contextWithScheduleDelay.ruleType.id, + rel: SAVED_OBJECT_REL_PRIMARY, + }, + ]); + expect(record.kibana?.space_ids).toEqual([contextWithScheduleDelay.spaceId]); + expect(record.kibana?.task?.scheduled).toEqual( + contextWithScheduleDelay.taskScheduledAt.toISOString() + ); + expect(record.kibana?.task?.schedule_delay).toEqual( + contextWithScheduleDelay.taskScheduleDelay * 1000000 + ); + expect(record?.rule?.id).toEqual(contextWithScheduleDelay.ruleId); + expect(record?.rule?.license).toEqual(contextWithScheduleDelay.ruleType.minimumLicenseRequired); + expect(record?.rule?.category).toEqual(contextWithScheduleDelay.ruleType.id); + expect(record?.rule?.ruleset).toEqual(contextWithScheduleDelay.ruleType.producer); + + // these fields should not be set by this function + expect(record['@timestamp']).toBeUndefined(); + expect(record.event?.provider).toBeUndefined(); + expect(record.event?.start).toBeUndefined(); + expect(record.event?.outcome).toBeUndefined(); + expect(record.event?.end).toBeUndefined(); + expect(record.event?.duration).toBeUndefined(); + expect(record.kibana?.alert?.rule?.execution?.metrics).toBeUndefined(); + expect(record.kibana?.alerting).toBeUndefined(); + expect(record.kibana?.server_uuid).toBeUndefined(); + expect(record.kibana?.version).toBeUndefined(); + expect(record?.rule?.name).toBeUndefined(); + expect(record?.message).toBeUndefined(); + expect(record?.ecs).toBeUndefined(); + }); +}); + +describe('createExecuteTimeoutRecord', () => { + test('should populate expected fields in event log record', () => { + const record = createExecuteTimeoutRecord(contextWithName); + + expect(record.event).toBeDefined(); + expect(record.kibana).toBeDefined(); + expect(record.kibana?.alert).toBeDefined(); + expect(record.kibana?.alert?.rule).toBeDefined(); + expect(record.kibana?.alert?.rule?.execution).toBeDefined(); + expect(record.kibana?.saved_objects).toBeDefined(); + expect(record.kibana?.space_ids).toBeDefined(); + expect(record.rule).toBeDefined(); + + // these fields should be explicitly set + expect(record.event?.action).toEqual('execute-timeout'); + expect(record.event?.kind).toEqual('alert'); + expect(record.message).toEqual( + `rule: test:123: 'my-super-cool-rule' execution cancelled due to timeout - exceeded rule type timeout of 1m` + ); + expect(record.event?.category).toEqual([contextWithName.ruleType.producer]); + expect(record.kibana?.alert?.rule?.rule_type_id).toEqual(contextWithName.ruleType.id); + expect(record.kibana?.alert?.rule?.consumer).toEqual(contextWithName.consumer); + expect(record.kibana?.alert?.rule?.execution?.uuid).toEqual(contextWithName.executionId); + expect(record.kibana?.saved_objects).toEqual([ + { + id: contextWithName.ruleId, + type: 'alert', + type_id: contextWithName.ruleType.id, + rel: SAVED_OBJECT_REL_PRIMARY, + }, + ]); + expect(record.kibana?.space_ids).toEqual([contextWithName.spaceId]); + expect(record?.rule?.id).toEqual(contextWithName.ruleId); + expect(record?.rule?.license).toEqual(contextWithName.ruleType.minimumLicenseRequired); + expect(record?.rule?.category).toEqual(contextWithName.ruleType.id); + expect(record?.rule?.ruleset).toEqual(contextWithName.ruleType.producer); + expect(record?.rule?.name).toEqual(contextWithName.ruleName); + + // these fields should not be set by this function + expect(record['@timestamp']).toBeUndefined(); + expect(record.event?.provider).toBeUndefined(); + expect(record.event?.start).toBeUndefined(); + expect(record.event?.outcome).toBeUndefined(); + expect(record.event?.end).toBeUndefined(); + expect(record.event?.duration).toBeUndefined(); + expect(record.kibana?.alert?.rule?.execution?.metrics).toBeUndefined(); + expect(record.kibana?.alerting).toBeUndefined(); + expect(record.kibana?.server_uuid).toBeUndefined(); + expect(record.kibana?.task).toBeUndefined(); + expect(record.kibana?.version).toBeUndefined(); + expect(record?.ecs).toBeUndefined(); + }); +}); + +describe('createAlertRecord', () => { + test('should populate expected fields in event log record', () => { + const record = createAlertRecord(contextWithName, alert); + + // these fields should be explicitly set + expect(record.event?.action).toEqual('active-instance'); + expect(record.event?.kind).toEqual('alert'); + expect(record.event?.category).toEqual([contextWithName.ruleType.producer]); + expect(record.event?.start).toEqual(alert.state.start); + expect(record.event?.end).toEqual(alert.state.end); + expect(record.event?.duration).toEqual(alert.state.duration); + expect(record.message).toEqual( + `.test-rule-type:123: 'my rule' active alert: 'aaabbb' in actionGroup: 'aGroup'; actionSubGroup: 'bSubGroup'` + ); + expect(record.kibana?.alert?.rule?.rule_type_id).toEqual(contextWithName.ruleType.id); + expect(record.kibana?.alert?.rule?.consumer).toEqual(contextWithName.consumer); + expect(record.kibana?.alert?.rule?.execution?.uuid).toEqual(contextWithName.executionId); + expect(record.kibana?.alerting?.instance_id).toEqual(alert.id); + expect(record.kibana?.alerting?.action_group_id).toEqual(alert.group); + expect(record.kibana?.alerting?.action_subgroup).toEqual(alert.subgroup); + expect(record.kibana?.saved_objects).toEqual([ + { + id: contextWithName.ruleId, + type: 'alert', + type_id: contextWithName.ruleType.id, + rel: SAVED_OBJECT_REL_PRIMARY, + }, + ]); + expect(record.kibana?.space_ids).toEqual([contextWithName.spaceId]); + expect(record?.rule?.id).toEqual(contextWithName.ruleId); + expect(record?.rule?.license).toEqual(contextWithName.ruleType.minimumLicenseRequired); + expect(record?.rule?.category).toEqual(contextWithName.ruleType.id); + expect(record?.rule?.ruleset).toEqual(contextWithName.ruleType.producer); + expect(record?.rule?.name).toEqual(contextWithName.ruleName); + + // these fields should not be set by this function + expect(record['@timestamp']).toBeUndefined(); + expect(record.event?.provider).toBeUndefined(); + expect(record.event?.outcome).toBeUndefined(); + expect(record.kibana?.alert?.rule?.execution?.metrics).toBeUndefined(); + expect(record.kibana?.server_uuid).toBeUndefined(); + expect(record.kibana?.task).toBeUndefined(); + expect(record.kibana?.version).toBeUndefined(); + expect(record?.ecs).toBeUndefined(); + }); +}); + +describe('createActionExecuteRecord', () => { + test('should populate expected fields in event log record', () => { + const record = createActionExecuteRecord(contextWithName, action); + + expect(record.event).toBeDefined(); + expect(record.kibana).toBeDefined(); + expect(record.kibana?.alert).toBeDefined(); + expect(record.kibana?.alert?.rule).toBeDefined(); + expect(record.kibana?.alert?.rule?.execution).toBeDefined(); + expect(record.kibana?.saved_objects).toBeDefined(); + expect(record.kibana?.space_ids).toBeDefined(); + expect(record.rule).toBeDefined(); + + // these fields should be explicitly set + expect(record.event?.action).toEqual('execute-action'); + expect(record.event?.kind).toEqual('alert'); + expect(record.event?.category).toEqual([contextWithName.ruleType.producer]); + expect(record.message).toEqual( + `alert: test:123: 'my-super-cool-rule' instanceId: '123' scheduled actionGroup(subgroup): 'aGroup(bSubgroup)' action: .email:abc` + ); + expect(record.kibana?.alert?.rule?.rule_type_id).toEqual(contextWithName.ruleType.id); + expect(record.kibana?.alert?.rule?.consumer).toEqual(contextWithName.consumer); + expect(record.kibana?.alert?.rule?.execution?.uuid).toEqual(contextWithName.executionId); + expect(record.kibana?.alerting?.instance_id).toEqual(action.alertId); + expect(record.kibana?.alerting?.action_group_id).toEqual(action.alertGroup); + expect(record.kibana?.alerting?.action_subgroup).toEqual(action.alertSubgroup); + expect(record.kibana?.saved_objects).toEqual([ + { + id: contextWithName.ruleId, + type: 'alert', + type_id: contextWithName.ruleType.id, + rel: SAVED_OBJECT_REL_PRIMARY, + }, + { + id: action.id, + type: 'action', + type_id: action.typeId, + }, + ]); + expect(record.kibana?.space_ids).toEqual([contextWithName.spaceId]); + expect(record?.rule?.id).toEqual(contextWithName.ruleId); + expect(record?.rule?.license).toEqual(contextWithName.ruleType.minimumLicenseRequired); + expect(record?.rule?.category).toEqual(contextWithName.ruleType.id); + expect(record?.rule?.ruleset).toEqual(contextWithName.ruleType.producer); + expect(record?.rule?.name).toEqual(contextWithName.ruleName); + + // these fields should not be set by this function + expect(record['@timestamp']).toBeUndefined(); + expect(record.event?.provider).toBeUndefined(); + expect(record.event?.start).toBeUndefined(); + expect(record.event?.outcome).toBeUndefined(); + expect(record.event?.end).toBeUndefined(); + expect(record.event?.duration).toBeUndefined(); + expect(record.kibana?.alert?.rule?.execution?.metrics).toBeUndefined(); + expect(record.kibana?.server_uuid).toBeUndefined(); + expect(record.kibana?.task).toBeUndefined(); + expect(record.kibana?.version).toBeUndefined(); + expect(record?.ecs).toBeUndefined(); + }); +}); + +describe('updateEvent', () => { + let event: IEvent; + let expectedEvent: IEvent; + beforeEach(() => { + event = initializeExecuteRecord(contextWithScheduleDelay); + expectedEvent = initializeExecuteRecord(contextWithScheduleDelay); + }); + + test('throws error if event is null', () => { + expect(() => updateEvent(null as unknown as IEvent, {})).toThrowErrorMatchingInlineSnapshot( + `"Cannot update event because it is not initialized."` + ); + }); + + test('throws error if event is undefined', () => { + expect(() => + updateEvent(undefined as unknown as IEvent, {}) + ).toThrowErrorMatchingInlineSnapshot(`"Cannot update event because it is not initialized."`); + }); + + test('updates event message if provided', () => { + updateEvent(event, { message: 'tell me something good' }); + expect(event).toEqual({ + ...expectedEvent, + message: 'tell me something good', + }); + }); + + test('updates event outcome if provided', () => { + updateEvent(event, { outcome: 'yay' }); + expect(event).toEqual({ + ...expectedEvent, + event: { + ...expectedEvent?.event, + outcome: 'yay', + }, + }); + }); + + test('updates event error if provided', () => { + updateEvent(event, { error: 'oh no' }); + expect(event).toEqual({ + ...expectedEvent, + error: { + message: 'oh no', + }, + }); + }); + + test('updates event rule name if provided', () => { + updateEvent(event, { ruleName: 'test rule' }); + expect(event).toEqual({ + ...expectedEvent, + rule: { + ...expectedEvent?.rule, + name: 'test rule', + }, + }); + }); + + test('updates event status if provided', () => { + updateEvent(event, { status: 'ok' }); + expect(event).toEqual({ + ...expectedEvent, + kibana: { + ...expectedEvent?.kibana, + alerting: { + status: 'ok', + }, + }, + }); + }); + + test('updates event reason if provided', () => { + updateEvent(event, { reason: 'my-reason' }); + expect(event).toEqual({ + ...expectedEvent, + event: { + ...expectedEvent?.event, + reason: 'my-reason', + }, + }); + }); + + test('updates all fields if provided', () => { + updateEvent(event, { + message: 'tell me something good', + outcome: 'yay', + error: 'oh no', + ruleName: 'test rule', + status: 'ok', + reason: 'my-reason', + }); + expect(event).toEqual({ + ...expectedEvent, + message: 'tell me something good', + kibana: { + ...expectedEvent?.kibana, + alerting: { + status: 'ok', + }, + }, + event: { + ...expectedEvent?.event, + outcome: 'yay', + reason: 'my-reason', + }, + error: { + message: 'oh no', + }, + rule: { + ...expectedEvent?.rule, + name: 'test rule', + }, + }); + }); +}); + +function mockEventLoggerStartTiming() { + eventLogger.startTiming.mockImplementationOnce((event: IEvent, startTime?: Date) => { + if (event == null) return; + event.event = event.event || {}; + + const start = startTime ?? new Date(); + event.event.start = start.toISOString(); + }); +} diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts new file mode 100644 index 000000000000000..74a8a26f531f22e --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts @@ -0,0 +1,389 @@ +/* + * 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 { IEvent, IEventLogger, SAVED_OBJECT_REL_PRIMARY } from '@kbn/event-log-plugin/server'; +import { EVENT_LOG_ACTIONS } from '../../plugin'; +import { UntypedNormalizedRuleType } from '../../rule_type_registry'; +import { AlertInstanceState, RuleExecutionStatus } from '../../types'; +import { createAlertEventLogRecordObject } from '../create_alert_event_log_record_object'; +import { RuleRunMetrics } from '../rule_run_metrics_store'; + +// 1,000,000 nanoseconds in 1 millisecond +const Millis2Nanos = 1000 * 1000; + +export interface RuleContextOpts { + ruleId: string; + ruleType: UntypedNormalizedRuleType; + consumer: string; + namespace?: string; + spaceId: string; + executionId: string; + taskScheduledAt: Date; + ruleName?: string; +} + +type RuleContext = RuleContextOpts & { + taskScheduleDelay: number; +}; + +interface DoneOpts { + status?: RuleExecutionStatus; + metrics?: RuleRunMetrics | null; +} + +interface AlertOpts { + action: string; + id: string; + message: string; + group?: string; + subgroup?: string; + state?: AlertInstanceState; +} + +interface ActionOpts { + id: string; + typeId: string; + alertId: string; + alertGroup?: string; + alertSubgroup?: string; +} + +export class AlertingEventLogger { + private eventLogger: IEventLogger; + private isInitialized = false; + private startTime?: Date; + private ruleContext?: RuleContextOpts; + + // this is the "execute" event that will be updated over the lifecycle of this class + private event: IEvent; + + constructor(eventLogger: IEventLogger) { + this.eventLogger = eventLogger; + } + + // For testing purposes + public getEvent(): IEvent { + return this.event; + } + + public initialize(context: RuleContextOpts) { + if (this.isInitialized) { + throw new Error('AlertingEventLogger already initialized'); + } + this.isInitialized = true; + this.ruleContext = context; + } + + public start() { + if (!this.isInitialized || !this.ruleContext) { + throw new Error('AlertingEventLogger not initialized'); + } + + this.startTime = new Date(); + + const context = { + ...this.ruleContext, + taskScheduleDelay: this.startTime.getTime() - this.ruleContext.taskScheduledAt.getTime(), + }; + + // Initialize the "execute" event + this.event = initializeExecuteRecord(context); + this.eventLogger.startTiming(this.event, this.startTime); + + // Create and log "execute-start" event + const executeStartEvent = createExecuteStartRecord(context, this.startTime); + this.eventLogger.logEvent(executeStartEvent); + } + + public getStartAndDuration(): { start?: Date; duration?: string | number } { + return { start: this.startTime, duration: this.event?.event?.duration }; + } + + public setRuleName(ruleName: string) { + if (!this.isInitialized || !this.event || !this.ruleContext) { + throw new Error('AlertingEventLogger not initialized'); + } + + this.ruleContext.ruleName = ruleName; + updateEvent(this.event, { ruleName }); + } + + public setExecutionSucceeded(message: string) { + if (!this.isInitialized || !this.event) { + throw new Error('AlertingEventLogger not initialized'); + } + + updateEvent(this.event, { message, outcome: 'success' }); + } + + public setExecutionFailed(message: string, errorMessage: string) { + if (!this.isInitialized || !this.event) { + throw new Error('AlertingEventLogger not initialized'); + } + + updateEvent(this.event, { message, outcome: 'failure', error: errorMessage }); + } + + public logTimeout() { + if (!this.isInitialized || !this.ruleContext) { + throw new Error('AlertingEventLogger not initialized'); + } + + this.eventLogger.logEvent(createExecuteTimeoutRecord(this.ruleContext)); + } + + public logAlert(alert: AlertOpts) { + if (!this.isInitialized || !this.ruleContext) { + throw new Error('AlertingEventLogger not initialized'); + } + + this.eventLogger.logEvent(createAlertRecord(this.ruleContext, alert)); + } + + public logAction(action: ActionOpts) { + if (!this.isInitialized || !this.ruleContext) { + throw new Error('AlertingEventLogger not initialized'); + } + + this.eventLogger.logEvent(createActionExecuteRecord(this.ruleContext, action)); + } + + public done({ status, metrics }: DoneOpts) { + if (!this.isInitialized || !this.event || !this.ruleContext) { + throw new Error('AlertingEventLogger not initialized'); + } + + this.eventLogger.stopTiming(this.event); + + if (status) { + updateEvent(this.event, { status: status.status }); + + if (status.error) { + updateEvent(this.event, { + outcome: 'failure', + reason: status.error?.reason || 'unknown', + error: this.event?.error?.message || status.error.message, + ...(this.event.message + ? {} + : { + message: `${this.ruleContext.ruleType.id}:${this.ruleContext.ruleId}: execution failed`, + }), + }); + } else { + if (status.warning) { + updateEvent(this.event, { + reason: status.warning?.reason || 'unknown', + message: status.warning?.message || this.event?.message, + }); + } + } + } + + if (metrics) { + updateEvent(this.event, { metrics }); + } + + this.eventLogger.logEvent(this.event); + } +} + +export function createExecuteStartRecord(context: RuleContext, startTime?: Date) { + const event = initializeExecuteRecord(context); + return { + ...event, + event: { + ...event.event, + action: EVENT_LOG_ACTIONS.executeStart, + ...(startTime ? { start: startTime.toISOString() } : {}), + }, + message: `rule execution start: "${context.ruleId}"`, + }; +} + +export function createAlertRecord(context: RuleContextOpts, alert: AlertOpts) { + return createAlertEventLogRecordObject({ + ruleId: context.ruleId, + ruleType: context.ruleType, + consumer: context.consumer, + namespace: context.namespace, + spaceId: context.spaceId, + executionId: context.executionId, + action: alert.action, + state: alert.state, + instanceId: alert.id, + group: alert.group, + subgroup: alert.subgroup, + message: alert.message, + savedObjects: [ + { + id: context.ruleId, + type: 'alert', + typeId: context.ruleType.id, + relation: SAVED_OBJECT_REL_PRIMARY, + }, + ], + ruleName: context.ruleName, + }); +} + +export function createActionExecuteRecord(context: RuleContextOpts, action: ActionOpts) { + return createAlertEventLogRecordObject({ + ruleId: context.ruleId, + ruleType: context.ruleType, + consumer: context.consumer, + namespace: context.namespace, + spaceId: context.spaceId, + executionId: context.executionId, + action: EVENT_LOG_ACTIONS.executeAction, + instanceId: action.alertId, + group: action.alertGroup, + subgroup: action.alertSubgroup, + message: `alert: ${context.ruleType.id}:${context.ruleId}: '${context.ruleName}' instanceId: '${ + action.alertId + }' scheduled ${ + action.alertSubgroup + ? `actionGroup(subgroup): '${action.alertGroup}(${action.alertSubgroup})'` + : `actionGroup: '${action.alertGroup}'` + } action: ${action.typeId}:${action.id}`, + savedObjects: [ + { + id: context.ruleId, + type: 'alert', + typeId: context.ruleType.id, + relation: SAVED_OBJECT_REL_PRIMARY, + }, + { + type: 'action', + id: action.id, + typeId: action.typeId, + }, + ], + ruleName: context.ruleName, + }); +} + +export function createExecuteTimeoutRecord(context: RuleContextOpts) { + return createAlertEventLogRecordObject({ + ruleId: context.ruleId, + ruleType: context.ruleType, + consumer: context.consumer, + namespace: context.namespace, + spaceId: context.spaceId, + executionId: context.executionId, + action: EVENT_LOG_ACTIONS.executeTimeout, + message: `rule: ${context.ruleType.id}:${context.ruleId}: '${ + context.ruleName ?? '' + }' execution cancelled due to timeout - exceeded rule type timeout of ${ + context.ruleType.ruleTaskTimeout + }`, + savedObjects: [ + { + id: context.ruleId, + type: 'alert', + typeId: context.ruleType.id, + relation: SAVED_OBJECT_REL_PRIMARY, + }, + ], + ruleName: context.ruleName, + }); +} + +export function initializeExecuteRecord(context: RuleContext) { + return createAlertEventLogRecordObject({ + ruleId: context.ruleId, + ruleType: context.ruleType, + consumer: context.consumer, + namespace: context.namespace, + spaceId: context.spaceId, + executionId: context.executionId, + action: EVENT_LOG_ACTIONS.execute, + task: { + scheduled: context.taskScheduledAt.toISOString(), + scheduleDelay: Millis2Nanos * context.taskScheduleDelay, + }, + savedObjects: [ + { + id: context.ruleId, + type: 'alert', + typeId: context.ruleType.id, + relation: SAVED_OBJECT_REL_PRIMARY, + }, + ], + }); +} + +interface UpdateEventOpts { + message?: string; + outcome?: string; + error?: string; + ruleName?: string; + status?: string; + reason?: string; + metrics?: RuleRunMetrics; +} +export function updateEvent(event: IEvent, opts: UpdateEventOpts) { + const { message, outcome, error, ruleName, status, reason, metrics } = opts; + if (!event) { + throw new Error('Cannot update event because it is not initialized.'); + } + if (message) { + event.message = message; + } + + if (outcome) { + event.event = event.event || {}; + event.event.outcome = outcome; + } + + if (error) { + event.error = event.error || {}; + event.error.message = error; + } + + if (ruleName) { + event.rule = { + ...event.rule, + name: ruleName, + }; + } + + if (status) { + event.kibana = event.kibana || {}; + event.kibana.alerting = event.kibana.alerting || {}; + event.kibana.alerting.status = status; + } + + if (reason) { + event.event = event.event || {}; + event.event.reason = reason; + } + + if (metrics) { + event.kibana = event.kibana || {}; + event.kibana.alert = event.kibana.alert || {}; + event.kibana.alert.rule = event.kibana.alert.rule || {}; + event.kibana.alert.rule.execution = event.kibana.alert.rule.execution || {}; + event.kibana.alert.rule.execution.metrics = { + number_of_triggered_actions: metrics.numberOfTriggeredActions + ? metrics.numberOfTriggeredActions + : 0, + number_of_generated_actions: metrics.numberOfGeneratedActions + ? metrics.numberOfGeneratedActions + : 0, + number_of_active_alerts: metrics.numberOfActiveAlerts ? metrics.numberOfActiveAlerts : 0, + number_of_new_alerts: metrics.numberOfNewAlerts ? metrics.numberOfNewAlerts : 0, + number_of_recovered_alerts: metrics.numberOfRecoveredAlerts + ? metrics.numberOfRecoveredAlerts + : 0, + total_number_of_alerts: + (metrics.numberOfActiveAlerts ?? 0) + (metrics.numberOfRecoveredAlerts ?? 0), + number_of_searches: metrics.numSearches ? metrics.numSearches : 0, + es_search_duration_ms: metrics.esSearchDurationMs ? metrics.esSearchDurationMs : 0, + total_search_duration_ms: metrics.totalSearchDurationMs ? metrics.totalSearchDurationMs : 0, + }; + } +} diff --git a/x-pack/plugins/alerting/server/lib/convert_rule_ids_to_kuery_node.test.ts b/x-pack/plugins/alerting/server/lib/convert_rule_ids_to_kuery_node.test.ts new file mode 100644 index 000000000000000..4696fed3154cc9c --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/convert_rule_ids_to_kuery_node.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { convertRuleIdsToKueryNode } from './convert_rule_ids_to_kuery_node'; + +describe('convertRuleIdsToKueryNode', () => { + test('should convert ids correctly', () => { + expect(convertRuleIdsToKueryNode(['1'])).toEqual({ + arguments: [ + { type: 'literal', value: 'alert.id' }, + { type: 'literal', value: 'alert:1' }, + { type: 'literal', value: false }, + ], + function: 'is', + type: 'function', + }); + }); + + test('should convert multiple ids correctly', () => { + expect(convertRuleIdsToKueryNode(['1', '22'])).toEqual({ + arguments: [ + { + arguments: [ + { + type: 'literal', + value: 'alert.id', + }, + { + type: 'literal', + value: 'alert:1', + }, + { + type: 'literal', + value: false, + }, + ], + function: 'is', + type: 'function', + }, + { + arguments: [ + { + type: 'literal', + value: 'alert.id', + }, + { + type: 'literal', + value: 'alert:22', + }, + { + type: 'literal', + value: false, + }, + ], + function: 'is', + type: 'function', + }, + ], + function: 'or', + type: 'function', + }); + }); + + test('should convert empty ids array correctly', () => { + expect(convertRuleIdsToKueryNode([])).toEqual(undefined); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/convert_rule_ids_to_kuery_node.ts b/x-pack/plugins/alerting/server/lib/convert_rule_ids_to_kuery_node.ts new file mode 100644 index 000000000000000..33f98b7b2ef5289 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/convert_rule_ids_to_kuery_node.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { nodeBuilder } from '@kbn/es-query'; + +/** + * This utility converts array of rule ids into qNode filter + */ + +export const convertRuleIdsToKueryNode = (ids: string[]) => + nodeBuilder.or(ids.map((ruleId) => nodeBuilder.is('alert.id', `alert:${ruleId}`))); diff --git a/x-pack/plugins/alerting/server/lib/index.ts b/x-pack/plugins/alerting/server/lib/index.ts index 57c9a92a8d91580..31528c0d50683d4 100644 --- a/x-pack/plugins/alerting/server/lib/index.ts +++ b/x-pack/plugins/alerting/server/lib/index.ts @@ -9,6 +9,7 @@ export { parseDuration, validateDurationSchema } from '../../common/parse_durati export type { ILicenseState } from './license_state'; export { LicenseState } from './license_state'; export { validateRuleTypeParams } from './validate_rule_type_params'; +export { validateMutatedRuleTypeParams } from './validate_mutated_rule_type_params'; export { getRuleNotifyWhenType } from './get_rule_notify_when_type'; export { verifyApiAccess } from './license_api_access'; export { ErrorWithReason, getReasonFromError, isErrorWithReason } from './error_with_reason'; @@ -26,3 +27,4 @@ export { } from './rule_execution_status'; export { getRecoveredAlerts } from './get_recovered_alerts'; export { createWrappedScopedClusterClientFactory } from './wrap_scoped_cluster_client'; +export { convertRuleIdsToKueryNode } from './convert_rule_ids_to_kuery_node'; diff --git a/x-pack/plugins/alerting/server/lib/validate_mutated_rule_type_params.ts b/x-pack/plugins/alerting/server/lib/validate_mutated_rule_type_params.ts new file mode 100644 index 000000000000000..52d7b768137b27b --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/validate_mutated_rule_type_params.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Boom from '@hapi/boom'; +import { RuleTypeParams, RuleTypeParamsValidator } from '../types'; + +export function validateMutatedRuleTypeParams( + mutatedParams: Params, + origParams?: Params, + validator?: RuleTypeParamsValidator +): Params { + if (!validator) { + return mutatedParams; + } + + try { + if (validator.validateMutatedParams) { + return validator.validateMutatedParams(mutatedParams, origParams); + } + return mutatedParams; + } catch (err) { + throw Boom.badRequest(`Mutated params invalid: ${err.message}`); + } +} diff --git a/x-pack/plugins/alerting/server/plugin.test.ts b/x-pack/plugins/alerting/server/plugin.test.ts index b342eddaa0c1b6f..5eba1353df21620 100644 --- a/x-pack/plugins/alerting/server/plugin.test.ts +++ b/x-pack/plugins/alerting/server/plugin.test.ts @@ -21,6 +21,7 @@ import { eventLogMock } from '@kbn/event-log-plugin/server/mocks'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; import { dataPluginMock } from '@kbn/data-plugin/server/mocks'; import { monitoringCollectionMock } from '@kbn/monitoring-collection-plugin/server/mocks'; +import { PluginSetup as DataPluginSetup } from '@kbn/data-plugin/server'; import { spacesMock } from '@kbn/spaces-plugin/server/mocks'; const generateAlertingConfig = (): AlertingConfig => ({ @@ -66,6 +67,7 @@ describe('Alerting Plugin', () => { actions: actionsMock.createSetup(), statusService: statusServiceMock.createSetupContract(), monitoringCollection: monitoringCollectionMock.createSetup(), + data: dataPluginMock.createSetupContract() as unknown as DataPluginSetup, }; let plugin: AlertingPlugin; @@ -207,6 +209,7 @@ describe('Alerting Plugin', () => { actions: actionsMock.createSetup(), statusService: statusServiceMock.createSetupContract(), monitoringCollection: monitoringCollectionMock.createSetup(), + data: dataPluginMock.createSetupContract() as unknown as DataPluginSetup, }); const startContract = plugin.start(coreMock.createStart(), { @@ -246,6 +249,7 @@ describe('Alerting Plugin', () => { actions: actionsMock.createSetup(), statusService: statusServiceMock.createSetupContract(), monitoringCollection: monitoringCollectionMock.createSetup(), + data: dataPluginMock.createSetupContract() as unknown as DataPluginSetup, }); const startContract = plugin.start(coreMock.createStart(), { @@ -296,6 +300,7 @@ describe('Alerting Plugin', () => { actions: actionsMock.createSetup(), statusService: statusServiceMock.createSetupContract(), monitoringCollection: monitoringCollectionMock.createSetup(), + data: dataPluginMock.createSetupContract() as unknown as DataPluginSetup, }); const startContract = plugin.start(coreMock.createStart(), { diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 6589b1537f766c0..063c221ea98db53 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -10,6 +10,7 @@ import { BehaviorSubject } from 'rxjs'; import { pick } from 'lodash'; import { UsageCollectionSetup, UsageCounter } from '@kbn/usage-collection-plugin/server'; import { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server'; +import { PluginSetup as DataPluginSetup } from '@kbn/data-plugin/server'; import { EncryptedSavedObjectsPluginSetup, EncryptedSavedObjectsPluginStart, @@ -140,6 +141,7 @@ export interface AlertingPluginsSetup { eventLog: IEventLogService; statusService: StatusServiceSetup; monitoringCollection: MonitoringCollectionSetup; + data: DataPluginSetup; } export interface AlertingPluginsStart { @@ -247,12 +249,16 @@ export class AlertingPlugin { // Usage counter for telemetry this.usageCounter = plugins.usageCollection?.createUsageCounter(ALERTS_FEATURE_ID); + const getSearchSourceMigrations = plugins.data.search.searchSource.getAllMigrations.bind( + plugins.data.search.searchSource + ); setupSavedObjects( core.savedObjects, plugins.encryptedSavedObjects, this.ruleTypeRegistry, this.logger, - plugins.actions.isPreconfiguredConnector + plugins.actions.isPreconfiguredConnector, + getSearchSourceMigrations ); initializeApiKeyInvalidator( diff --git a/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts b/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts index 7123f1bf4ad6c4e..8c24b457df5656b 100644 --- a/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts +++ b/x-pack/plugins/alerting/server/routes/aggregate_rules.test.ts @@ -60,6 +60,7 @@ describe('aggregateRulesRoute', () => { ruleSnoozedStatus: { snoozed: 4, }, + ruleTags: ['a', 'b', 'c'], }; rulesClient.aggregate.mockResolvedValueOnce(aggregateResult); @@ -94,6 +95,11 @@ describe('aggregateRulesRoute', () => { "rule_snoozed_status": Object { "snoozed": 4, }, + "rule_tags": Array [ + "a", + "b", + "c", + ], }, } `); @@ -129,6 +135,7 @@ describe('aggregateRulesRoute', () => { rule_snoozed_status: { snoozed: 4, }, + rule_tags: ['a', 'b', 'c'], }, }); }); diff --git a/x-pack/plugins/alerting/server/routes/aggregate_rules.ts b/x-pack/plugins/alerting/server/routes/aggregate_rules.ts index 312def72dd65e43..c48c74fc2875494 100644 --- a/x-pack/plugins/alerting/server/routes/aggregate_rules.ts +++ b/x-pack/plugins/alerting/server/routes/aggregate_rules.ts @@ -50,6 +50,7 @@ const rewriteBodyRes: RewriteResponseCase = ({ ruleEnabledStatus, ruleMutedStatus, ruleSnoozedStatus, + ruleTags, ...rest }) => ({ ...rest, @@ -57,6 +58,7 @@ const rewriteBodyRes: RewriteResponseCase = ({ rule_enabled_status: ruleEnabledStatus, rule_muted_status: ruleMutedStatus, rule_snoozed_status: ruleSnoozedStatus, + rule_tags: ruleTags, }); export const aggregateRulesRoute = ( diff --git a/x-pack/plugins/alerting/server/routes/bulk_edit_rules.test.ts b/x-pack/plugins/alerting/server/routes/bulk_edit_rules.test.ts new file mode 100644 index 000000000000000..b70e4734ab4ffed --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/bulk_edit_rules.test.ts @@ -0,0 +1,188 @@ +/* + * 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 { httpServiceMock } from '@kbn/core/server/mocks'; + +import { bulkEditInternalRulesRoute } from './bulk_edit_rules'; +import { licenseStateMock } from '../lib/license_state.mock'; +import { verifyApiAccess } from '../lib/license_api_access'; +import { RuleTypeDisabledError } from '../lib/errors/rule_type_disabled'; +import { mockHandlerArguments } from './_mock_handler_arguments'; +import { rulesClientMock } from '../rules_client.mock'; +import { SanitizedRule } from '../types'; + +const rulesClient = rulesClientMock.create(); +jest.mock('../lib/license_api_access', () => ({ + verifyApiAccess: jest.fn(), +})); +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('bulkEditInternalRulesRoute', () => { + const mockedAlert: SanitizedRule<{}> = { + id: '1', + alertTypeId: '1', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: new Date(), + updatedAt: new Date(), + actions: [ + { + group: 'default', + id: '2', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + consumer: 'bar', + name: 'abc', + tags: ['foo'], + enabled: true, + muteAll: false, + notifyWhen: 'onActionGroupChange', + createdBy: '', + updatedBy: '', + apiKeyOwner: '', + throttle: '30s', + mutedInstanceIds: [], + executionStatus: { + status: 'unknown', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + }, + }; + + const mockedAlerts: Array> = [mockedAlert]; + const bulkEditRequest = { + filter: '', + operations: [ + { + action: 'add', + field: 'tags', + value: ['alerting-1'], + }, + ], + }; + const bulkEditResult = { rules: mockedAlerts, errors: [], total: 1 }; + + it('bulk edits rules with tags action', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + bulkEditInternalRulesRoute(router, licenseState); + + const [config, handler] = router.post.mock.calls[0]; + + expect(config.path).toBe('/internal/alerting/rules/_bulk_edit'); + + rulesClient.bulkEdit.mockResolvedValueOnce(bulkEditResult); + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + body: bulkEditRequest, + }, + ['ok'] + ); + + expect(await handler(context, req, res)).toEqual({ + body: { + total: 1, + errors: [], + rules: [ + expect.objectContaining({ + id: '1', + name: 'abc', + tags: ['foo'], + actions: [ + { + group: 'default', + id: '2', + connector_type_id: 'test', + params: { + foo: true, + }, + }, + ], + }), + ], + }, + }); + + expect(rulesClient.bulkEdit).toHaveBeenCalledTimes(1); + expect(rulesClient.bulkEdit.mock.calls[0]).toEqual([bulkEditRequest]); + + expect(res.ok).toHaveBeenCalled(); + }); + + it('ensures the license allows bulk editing rules', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + rulesClient.bulkEdit.mockResolvedValueOnce(bulkEditResult); + + bulkEditInternalRulesRoute(router, licenseState); + + const [, handler] = router.post.mock.calls[0]; + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + body: bulkEditRequest, + } + ); + + await handler(context, req, res); + + expect(verifyApiAccess).toHaveBeenCalledWith(licenseState); + }); + + it('ensures the license check prevents bulk editing rules', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + (verifyApiAccess as jest.Mock).mockImplementation(() => { + throw new Error('Failure'); + }); + + bulkEditInternalRulesRoute(router, licenseState); + + const [, handler] = router.post.mock.calls[0]; + + const [context, req, res] = mockHandlerArguments( + { rulesClient }, + { + body: bulkEditRequest, + } + ); + + expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: Failure]`); + }); + + it('ensures the rule type gets validated for the license', async () => { + const licenseState = licenseStateMock.create(); + const router = httpServiceMock.createRouter(); + + bulkEditInternalRulesRoute(router, licenseState); + + const [, handler] = router.post.mock.calls[0]; + + rulesClient.bulkEdit.mockRejectedValue(new RuleTypeDisabledError('Fail', 'license_invalid')); + + const [context, req, res] = mockHandlerArguments({ rulesClient }, { params: {}, body: {} }, [ + 'ok', + 'forbidden', + ]); + + await handler(context, req, res); + + expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } }); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/bulk_edit_rules.ts b/x-pack/plugins/alerting/server/routes/bulk_edit_rules.ts new file mode 100644 index 000000000000000..6588a46e1d91413 --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/bulk_edit_rules.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; +import { IRouter } from '@kbn/core/server'; + +import { ILicenseState, RuleTypeDisabledError } from '../lib'; +import { verifyAccessAndContext, rewriteRule, handleDisabledApiKeysError } from './lib'; +import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types'; + +const ruleActionSchema = schema.object({ + group: schema.string(), + id: schema.string(), + params: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }), +}); + +const operationsSchema = schema.arrayOf( + schema.oneOf([ + schema.object({ + operation: schema.oneOf([ + schema.literal('add'), + schema.literal('delete'), + schema.literal('set'), + ]), + field: schema.literal('tags'), + value: schema.arrayOf(schema.string()), + }), + schema.object({ + operation: schema.oneOf([schema.literal('add'), schema.literal('set')]), + field: schema.literal('actions'), + value: schema.arrayOf(ruleActionSchema), + }), + ]), + { minSize: 1 } +); + +const bodySchema = schema.object({ + filter: schema.maybe(schema.string()), + ids: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })), + operations: operationsSchema, +}); + +interface BuildBulkEditRulesRouteParams { + licenseState: ILicenseState; + path: string; + router: IRouter; +} + +const buildBulkEditRulesRoute = ({ licenseState, path, router }: BuildBulkEditRulesRouteParams) => { + router.post( + { + path, + validate: { + body: bodySchema, + }, + }, + handleDisabledApiKeysError( + router.handleLegacyErrors( + verifyAccessAndContext(licenseState, async function (context, req, res) { + const rulesClient = (await context.alerting).getRulesClient(); + const { filter, operations, ids } = req.body; + + try { + const bulkEditResults = await rulesClient.bulkEdit({ + filter, + ids: ids as string[], + operations, + }); + return res.ok({ + body: { ...bulkEditResults, rules: bulkEditResults.rules.map(rewriteRule) }, + }); + } catch (e) { + if (e instanceof RuleTypeDisabledError) { + return e.sendResponse(res); + } + throw e; + } + }) + ) + ) + ); +}; + +export const bulkEditInternalRulesRoute = ( + router: IRouter, + licenseState: ILicenseState +) => + buildBulkEditRulesRoute({ + licenseState, + path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/_bulk_edit`, + router, + }); diff --git a/x-pack/plugins/alerting/server/routes/find_rules.ts b/x-pack/plugins/alerting/server/routes/find_rules.ts index ef8b8b29057c0fc..725c7fc2a69dd8f 100644 --- a/x-pack/plugins/alerting/server/routes/find_rules.ts +++ b/x-pack/plugins/alerting/server/routes/find_rules.ts @@ -5,13 +5,17 @@ * 2.0. */ -import { omit } from 'lodash'; import { IRouter } from '@kbn/core/server'; import { UsageCounter } from '@kbn/usage-collection-plugin/server'; import { schema } from '@kbn/config-schema'; import { ILicenseState } from '../lib'; import { FindOptions, FindResult } from '../rules_client'; -import { RewriteRequestCase, RewriteResponseCase, verifyAccessAndContext } from './lib'; +import { + RewriteRequestCase, + RewriteResponseCase, + verifyAccessAndContext, + rewriteRule, +} from './lib'; import { RuleTypeParams, AlertingRequestHandlerContext, @@ -70,49 +74,7 @@ const rewriteBodyRes: RewriteResponseCase> = ({ return { ...restOfResult, per_page: perPage, - data: data.map( - ({ - alertTypeId, - createdBy, - updatedBy, - createdAt, - updatedAt, - apiKeyOwner, - notifyWhen, - muteAll, - mutedInstanceIds, - executionStatus, - actions, - scheduledTaskId, - snoozeEndTime, - ...rest - }) => ({ - ...rest, - rule_type_id: alertTypeId, - created_by: createdBy, - updated_by: updatedBy, - created_at: createdAt, - updated_at: updatedAt, - api_key_owner: apiKeyOwner, - notify_when: notifyWhen, - 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 } : {}), - execution_status: executionStatus && { - ...omit(executionStatus, 'lastExecutionDate', 'lastDuration'), - last_execution_date: executionStatus.lastExecutionDate, - last_duration: executionStatus.lastDuration, - }, - actions: actions.map(({ group, id, actionTypeId, params }) => ({ - group, - id, - params, - connector_type_id: actionTypeId, - })), - }) - ), + data: data.map(rewriteRule), }; }; diff --git a/x-pack/plugins/alerting/server/routes/index.ts b/x-pack/plugins/alerting/server/routes/index.ts index 392ec591d960879..2ef75ce269220de 100644 --- a/x-pack/plugins/alerting/server/routes/index.ts +++ b/x-pack/plugins/alerting/server/routes/index.ts @@ -30,6 +30,7 @@ import { muteAlertRoute } from './mute_alert'; import { unmuteAllRuleRoute } from './unmute_all_rule'; import { unmuteAlertRoute } from './unmute_alert'; import { updateRuleApiKeyRoute } from './update_rule_api_key'; +import { bulkEditInternalRulesRoute } from './bulk_edit_rules'; import { snoozeRuleRoute } from './snooze_rule'; import { unsnoozeRuleRoute } from './unsnooze_rule'; @@ -65,6 +66,7 @@ export function defineRoutes(opts: RouteOptions) { unmuteAllRuleRoute(router, licenseState); unmuteAlertRoute(router, licenseState); updateRuleApiKeyRoute(router, licenseState); + bulkEditInternalRulesRoute(router, licenseState); snoozeRuleRoute(router, licenseState); unsnoozeRuleRoute(router, licenseState); } diff --git a/x-pack/plugins/alerting/server/routes/lib/index.ts b/x-pack/plugins/alerting/server/routes/lib/index.ts index 2c14660ae47de0d..e772f091bb0591e 100644 --- a/x-pack/plugins/alerting/server/routes/lib/index.ts +++ b/x-pack/plugins/alerting/server/routes/lib/index.ts @@ -18,3 +18,4 @@ export type { } from './rewrite_request_case'; export { verifyAccessAndContext } from './verify_access_and_context'; export { countUsageOfPredefinedIds } from './count_usage_of_predefined_ids'; +export { rewriteRule } from './rewrite_rule'; diff --git a/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts new file mode 100644 index 000000000000000..537d42bbc4f470a --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/lib/rewrite_rule.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { omit } from 'lodash'; + +import { RuleTypeParams, SanitizedRule } from '../../types'; + +export const rewriteRule = ({ + alertTypeId, + createdBy, + updatedBy, + createdAt, + updatedAt, + apiKeyOwner, + notifyWhen, + muteAll, + mutedInstanceIds, + executionStatus, + actions, + scheduledTaskId, + snoozeEndTime, + ...rest +}: SanitizedRule) => ({ + ...rest, + rule_type_id: alertTypeId, + created_by: createdBy, + updated_by: updatedBy, + created_at: createdAt, + updated_at: updatedAt, + api_key_owner: apiKeyOwner, + notify_when: notifyWhen, + 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 } : {}), + execution_status: executionStatus && { + ...omit(executionStatus, 'lastExecutionDate', 'lastDuration'), + last_execution_date: executionStatus.lastExecutionDate, + last_duration: executionStatus.lastDuration, + }, + actions: actions.map(({ group, id, actionTypeId, params }) => ({ + group, + id, + params, + connector_type_id: actionTypeId, + })), +}); diff --git a/x-pack/plugins/alerting/server/rules_client.mock.ts b/x-pack/plugins/alerting/server/rules_client.mock.ts index bc5c9c0a5e0cd0f..302824221ded834 100644 --- a/x-pack/plugins/alerting/server/rules_client.mock.ts +++ b/x-pack/plugins/alerting/server/rules_client.mock.ts @@ -32,6 +32,7 @@ const createRulesClientMock = () => { getAlertSummary: jest.fn(), getExecutionLogForRule: jest.fn(), getSpaceId: jest.fn(), + bulkEdit: jest.fn(), snooze: jest.fn(), unsnooze: jest.fn(), }; diff --git a/x-pack/plugins/alerting/server/rules_client/audit_events.ts b/x-pack/plugins/alerting/server/rules_client/audit_events.ts index a60cc503d3c8e19..0d722498f50aa19 100644 --- a/x-pack/plugins/alerting/server/rules_client/audit_events.ts +++ b/x-pack/plugins/alerting/server/rules_client/audit_events.ts @@ -23,6 +23,7 @@ export enum RuleAuditAction { MUTE_ALERT = 'rule_alert_mute', UNMUTE_ALERT = 'rule_alert_unmute', AGGREGATE = 'rule_aggregate', + BULK_EDIT = 'rule_bulk_edit', GET_EXECUTION_LOG = 'rule_get_execution_log', SNOOZE = 'rule_snooze', UNSNOOZE = 'rule_unsnooze', @@ -35,6 +36,7 @@ const eventVerbs: Record = { rule_get: ['access', 'accessing', 'accessed'], rule_resolve: ['access', 'accessing', 'accessed'], rule_update: ['update', 'updating', 'updated'], + rule_bulk_edit: ['update', 'updating', 'updated'], rule_update_api_key: ['update API key of', 'updating API key of', 'updated API key of'], rule_enable: ['enable', 'enabling', 'enabled'], rule_disable: ['disable', 'disabling', 'disabled'], @@ -59,6 +61,7 @@ const eventTypes: Record = { rule_get: 'access', rule_resolve: 'access', rule_update: 'change', + rule_bulk_edit: 'change', rule_update_api_key: 'change', rule_enable: 'change', rule_disable: 'change', diff --git a/x-pack/plugins/alerting/server/rules_client/lib/apply_bulk_edit_operation.test.ts b/x-pack/plugins/alerting/server/rules_client/lib/apply_bulk_edit_operation.test.ts new file mode 100644 index 000000000000000..8ba3b551073cfc6 --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/lib/apply_bulk_edit_operation.test.ts @@ -0,0 +1,171 @@ +/* + * 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 { applyBulkEditOperation } from './apply_bulk_edit_operation'; +import type { Rule } from '../../types'; + +describe('applyBulkEditOperation', () => { + describe('tags operations', () => { + test('should add tag', () => { + const ruleMock: Partial = { + tags: ['tag-1', 'tag-2'], + }; + expect( + applyBulkEditOperation( + { + field: 'tags', + value: ['add-tag'], + operation: 'add', + }, + ruleMock + ) + ).toHaveProperty('tags', ['tag-1', 'tag-2', 'add-tag']); + }); + + test('should add multiple tags', () => { + const ruleMock: Partial = { + tags: ['tag-1', 'tag-2'], + }; + expect( + applyBulkEditOperation( + { + field: 'tags', + value: ['add-tag-1', 'add-tag-2'], + operation: 'add', + }, + ruleMock + ) + ).toHaveProperty('tags', ['tag-1', 'tag-2', 'add-tag-1', 'add-tag-2']); + }); + + test('should not have duplicated tags when added existed ones', () => { + const ruleMock: Partial = { + tags: ['tag-1', 'tag-2'], + }; + expect( + applyBulkEditOperation( + { + field: 'tags', + value: ['tag-1', 'tag-3'], + operation: 'add', + }, + ruleMock + ) + ).toHaveProperty('tags', ['tag-1', 'tag-2', 'tag-3']); + }); + + test('should delete tag', () => { + const ruleMock: Partial = { + tags: ['tag-1', 'tag-2'], + }; + expect( + applyBulkEditOperation( + { + field: 'tags', + value: ['tag-1'], + operation: 'delete', + }, + ruleMock + ) + ).toHaveProperty('tags', ['tag-2']); + }); + + test('should delete multiple tags', () => { + const ruleMock: Partial = { + tags: ['tag-1', 'tag-2'], + }; + expect( + applyBulkEditOperation( + { + field: 'tags', + value: ['tag-1', 'tag-2'], + operation: 'delete', + }, + ruleMock + ) + ).toHaveProperty('tags', []); + }); + + test('should rewrite tags', () => { + const ruleMock: Partial = { + tags: ['tag-1', 'tag-2'], + }; + expect( + applyBulkEditOperation( + { + field: 'tags', + value: ['rewrite-tag'], + operation: 'set', + }, + ruleMock + ) + ).toHaveProperty('tags', ['rewrite-tag']); + }); + }); + + describe('actions operations', () => { + test('should add actions', () => { + const ruleMock = { + actions: [{ id: 'mock-action-id', group: 'default', params: {} }], + }; + expect( + applyBulkEditOperation( + { + field: 'actions', + value: [ + { id: 'mock-add-action-id-1', group: 'default', params: {} }, + { id: 'mock-add-action-id-2', group: 'default', params: {} }, + ], + operation: 'add', + }, + ruleMock + ) + ).toHaveProperty('actions', [ + { id: 'mock-action-id', group: 'default', params: {} }, + { id: 'mock-add-action-id-1', group: 'default', params: {} }, + { id: 'mock-add-action-id-2', group: 'default', params: {} }, + ]); + }); + + test('should add action with different params and same id', () => { + const ruleMock = { + actions: [{ id: 'mock-action-id', group: 'default', params: { test: 1 } }], + }; + expect( + applyBulkEditOperation( + { + field: 'actions', + value: [{ id: 'mock-action-id', group: 'default', params: { test: 2 } }], + operation: 'add', + }, + ruleMock + ) + ).toHaveProperty('actions', [ + { id: 'mock-action-id', group: 'default', params: { test: 1 } }, + { id: 'mock-action-id', group: 'default', params: { test: 2 } }, + ]); + }); + + test('should rewrite actions', () => { + const ruleMock = { + actions: [{ id: 'mock-action-id', group: 'default', params: {} }], + }; + expect( + applyBulkEditOperation( + { + field: 'actions', + value: [{ id: 'mock-rewrite-action-id-1', group: 'default', params: {} }], + operation: 'set', + }, + ruleMock + ) + ).toHaveProperty('actions', [ + { id: 'mock-rewrite-action-id-1', group: 'default', params: {} }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/rules_client/lib/apply_bulk_edit_operation.ts b/x-pack/plugins/alerting/server/rules_client/lib/apply_bulk_edit_operation.ts new file mode 100644 index 000000000000000..a41e3f0dfa7f887 --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/lib/apply_bulk_edit_operation.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { set, get } from 'lodash'; +import type { BulkEditOperation, BulkEditFields } from '../rules_client'; + +// defining an union type that will passed directly to generic function as a workaround for the issue similar to +// https://github.com/microsoft/TypeScript/issues/29479 +type AddItemToArray = + | Extract }>['value'][number] + | Extract }>['value'][number]; + +/** + * this method takes BulkEdit operation and applies it to rule, by mutating it + * @param operation BulkEditOperation + * @param rule object rule to update + * @returns modified rule + */ +export const applyBulkEditOperation = (operation: BulkEditOperation, rule: R) => { + const addItemsToArray = (arr: T[], items: T[]): T[] => Array.from(new Set([...arr, ...items])); + + const deleteItemsFromArray = (arr: T[], items: T[]): T[] => { + const itemsSet = new Set(items); + return arr.filter((item) => !itemsSet.has(item)); + }; + + switch (operation.operation) { + case 'set': + set(rule, operation.field, operation.value); + break; + + case 'add': + set( + rule, + operation.field, + addItemsToArray(get(rule, operation.field) ?? [], operation.value) + ); + break; + + case 'delete': + set( + rule, + operation.field, + deleteItemsFromArray(get(rule, operation.field) ?? [], operation.value) + ); + break; + } + + return rule; +}; diff --git a/x-pack/plugins/alerting/server/rules_client/lib/index.ts b/x-pack/plugins/alerting/server/rules_client/lib/index.ts index 3ad3e1187706478..fc31cd6a98d5552 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/index.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/index.ts @@ -7,3 +7,5 @@ export { mapSortField } from './map_sort_field'; export { validateOperationOnAttributes } from './validate_attributes'; +export { retryIfBulkEditConflicts } from './retry_if_bulk_edit_conflicts'; +export { applyBulkEditOperation } from './apply_bulk_edit_operation'; diff --git a/x-pack/plugins/alerting/server/rules_client/lib/retry_if_bulk_edit_conflicts.test.ts b/x-pack/plugins/alerting/server/rules_client/lib/retry_if_bulk_edit_conflicts.test.ts new file mode 100644 index 000000000000000..ae2a83614ac20c2 --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/lib/retry_if_bulk_edit_conflicts.test.ts @@ -0,0 +1,146 @@ +/* + * 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 { KueryNode } from '@kbn/es-query'; + +import { + retryIfBulkEditConflicts, + RetryForConflictsAttempts, +} from './retry_if_bulk_edit_conflicts'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; + +const mockFilter: KueryNode = { + type: 'function', + value: 'mock', +}; + +const mockOperationName = 'conflict-retryable-operation'; +const mockLogger = loggingSystemMock.create().get(); +const mockSuccessfulResult = { + apiKeysToInvalidate: [], + rules: [ + { id: '1', type: 'alert', attributes: {} }, + { id: '2', type: 'alert', attributes: { name: 'Test rule 2' } }, + ], + resultSavedObjects: [ + { id: '1', type: 'alert', attributes: {}, references: [] }, + { id: '2', type: 'alert', attributes: { name: 'Test rule 2' }, references: [] }, + ], + errors: [], +}; + +async function OperationSuccessful() { + return mockSuccessfulResult; +} + +const conflictOperationMock = jest.fn(); + +function getOperationConflictsTimes(times: number) { + return async function OperationConflictsTimes() { + conflictOperationMock(); + times--; + if (times >= 0) { + return { + ...mockSuccessfulResult, + resultSavedObjects: [ + { id: '1', type: 'alert', attributes: {}, references: [] }, + { + id: '2', + type: 'alert', + attributes: {}, + references: [], + error: { + statusCode: 409, + error: 'Conflict', + message: 'Saved object [alert/2] conflict', + }, + }, + ], + }; + } + return mockSuccessfulResult; + }; +} + +describe('retryIfBulkEditConflicts', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('should work when operation is a success', async () => { + const result = await retryIfBulkEditConflicts( + mockLogger, + mockOperationName, + OperationSuccessful, + mockFilter + ); + expect(result).toEqual({ + apiKeysToInvalidate: [], + errors: [], + results: [ + { + attributes: {}, + id: '1', + references: [], + type: 'alert', + }, + { + attributes: { + name: 'Test rule 2', + }, + id: '2', + references: [], + type: 'alert', + }, + ], + }); + }); + + test(`should throw error when operation fails`, async () => { + await expect( + retryIfBulkEditConflicts( + mockLogger, + mockOperationName, + async () => { + throw Error('Test failure'); + }, + mockFilter + ) + ).rejects.toThrowError('Test failure'); + }); + + test(`should return conflict errors when number of retries exceeds ${RetryForConflictsAttempts}`, async () => { + const result = await retryIfBulkEditConflicts( + mockLogger, + mockOperationName, + getOperationConflictsTimes(RetryForConflictsAttempts + 1), + mockFilter + ); + + expect(result.errors).toEqual([ + { + message: 'Saved object [alert/2] conflict', + rule: { + id: '2', + name: 'Test rule 2', + }, + }, + ]); + expect(mockLogger.warn).toBeCalledWith(`${mockOperationName} conflicts, exceeded retries`); + }); + + for (let i = 1; i <= RetryForConflictsAttempts; i++) { + test(`should work when operation conflicts ${i} times`, async () => { + const result = await retryIfBulkEditConflicts( + mockLogger, + mockOperationName, + getOperationConflictsTimes(i), + mockFilter + ); + expect(result).toBe(result); + }); + } +}); diff --git a/x-pack/plugins/alerting/server/rules_client/lib/retry_if_bulk_edit_conflicts.ts b/x-pack/plugins/alerting/server/rules_client/lib/retry_if_bulk_edit_conflicts.ts new file mode 100644 index 000000000000000..9e1e60acb768fb6 --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/lib/retry_if_bulk_edit_conflicts.ts @@ -0,0 +1,166 @@ +/* + * 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 pMap from 'p-map'; +import { chunk } from 'lodash'; +import { KueryNode } from '@kbn/es-query'; +import { Logger, SavedObjectsBulkUpdateObject, SavedObjectsUpdateResponse } from '@kbn/core/server'; +import { convertRuleIdsToKueryNode } from '../../lib'; +import { BulkEditError } from '../rules_client'; +import { RawRule } from '../../types'; + +// number of times to retry when conflicts occur +export const RetryForConflictsAttempts = 2; + +// milliseconds to wait before retrying when conflicts occur +// note: we considered making this random, to help avoid a stampede, but +// with 1 retry it probably doesn't matter, and adding randomness could +// make it harder to diagnose issues +const RetryForConflictsDelay = 250; + +// max number of failed SO ids in one retry filter +const MaxIdsNumberInRetryFilter = 1000; + +type BulkEditOperation = (filter: KueryNode | null) => Promise<{ + apiKeysToInvalidate: string[]; + rules: Array>; + resultSavedObjects: Array>; + errors: BulkEditError[]; +}>; + +interface ReturnRetry { + apiKeysToInvalidate: string[]; + results: Array>; + errors: BulkEditError[]; +} + +/** + * Retries BulkEdit requests + * If in response are presents conflicted savedObjects(409 statusCode), this util constructs filter with failed SO ids and retries bulkEdit operation until + * all SO updated or number of retries exceeded + * @param logger + * @param name + * @param bulkEditOperation + * @param filter - KueryNode filter + * @param retries - number of retries left + * @param accApiKeysToInvalidate - accumulated apiKeys that need to be invalidated + * @param accResults - accumulated updated savedObjects + * @param accErrors - accumulated conflict errors + * @returns Promise + */ +export const retryIfBulkEditConflicts = async ( + logger: Logger, + name: string, + bulkEditOperation: BulkEditOperation, + filter: KueryNode | null, + retries: number = RetryForConflictsAttempts, + accApiKeysToInvalidate: string[] = [], + accResults: Array> = [], + accErrors: BulkEditError[] = [] +): Promise => { + // run the operation, return if no errors or throw if not a conflict error + try { + const { + apiKeysToInvalidate: localApiKeysToInvalidate, + resultSavedObjects, + errors: localErrors, + rules: localRules, + } = await bulkEditOperation(filter); + + const conflictErrorMap = resultSavedObjects.reduce>( + (acc, item) => { + if (item.type === 'alert' && item?.error?.statusCode === 409) { + return acc.set(item.id, { message: item.error.message }); + } + return acc; + }, + new Map() + ); + + const results = [...accResults, ...resultSavedObjects.filter((res) => res.error === undefined)]; + const apiKeysToInvalidate = [...accApiKeysToInvalidate, ...localApiKeysToInvalidate]; + const errors = [...accErrors, ...localErrors]; + + if (conflictErrorMap.size === 0) { + return { + apiKeysToInvalidate, + results, + errors, + }; + } + + if (retries <= 0) { + logger.warn(`${name} conflicts, exceeded retries`); + + const conflictErrors = localRules + .filter((obj) => conflictErrorMap.has(obj.id)) + .map((obj) => ({ + message: conflictErrorMap.get(obj.id)?.message ?? 'n/a', + rule: { + id: obj.id, + name: obj.attributes?.name ?? 'n/a', + }, + })); + + return { + apiKeysToInvalidate, + results, + errors: [...errors, ...conflictErrors], + }; + } + + const ids = Array.from(conflictErrorMap.keys()); + logger.debug(`${name} conflicts, retrying ..., ${ids.length} saved objects conflicted`); + + // delay before retry + await waitBeforeNextRetry(retries); + + // here, we construct filter query with ids. But, due to a fact that number of conflicted saved objects can exceed few thousands we can encounter following error: + // "all shards failed: search_phase_execution_exception: [query_shard_exception] Reason: failed to create query: maxClauseCount is set to 2621" + // That's why we chunk processing ids into pieces by size equals to MaxIdsNumberInRetryFilter + return ( + await pMap( + chunk(ids, MaxIdsNumberInRetryFilter), + async (queryIds) => + retryIfBulkEditConflicts( + logger, + name, + bulkEditOperation, + convertRuleIdsToKueryNode(queryIds), + retries - 1, + apiKeysToInvalidate, + results, + errors + ), + { + concurrency: 1, + } + ) + ).reduce( + (acc, item) => { + return { + results: [...acc.results, ...item.results], + apiKeysToInvalidate: [...acc.apiKeysToInvalidate, ...item.apiKeysToInvalidate], + errors: [...acc.errors, ...item.errors], + }; + }, + { results: [], apiKeysToInvalidate: [], errors: [] } + ); + } catch (err) { + throw err; + } +}; + +// exponential delay before retry with adding random delay +async function waitBeforeNextRetry(retries: number): Promise { + const exponentialDelayMultiplier = 1 + (RetryForConflictsAttempts - retries) ** 2; + const randomDelayMs = Math.floor(Math.random() * 100); + + await new Promise((resolve) => + setTimeout(resolve, RetryForConflictsDelay * exponentialDelayMultiplier + randomDelayMs) + ); +} 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 00f67437ae4f2d2..75398a66687552a 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -6,8 +6,9 @@ */ import Semver from 'semver'; +import pMap from 'p-map'; import Boom from '@hapi/boom'; -import { omit, isEqual, map, uniq, pick, truncate, trim, mapValues } from 'lodash'; +import { omit, isEqual, map, uniq, pick, truncate, trim, mapValues, cloneDeep } from 'lodash'; import { i18n } from '@kbn/i18n'; import { fromKueryExpression, KueryNode, nodeBuilder } from '@kbn/es-query'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; @@ -19,6 +20,8 @@ import { PluginInitializerContext, SavedObjectsUtils, SavedObjectAttributes, + SavedObjectsBulkUpdateObject, + SavedObjectsUpdateResponse, } from '@kbn/core/server'; import { ActionsClient, ActionsAuthorization } from '@kbn/actions-plugin/server'; import { @@ -53,7 +56,13 @@ import { PartialRuleWithLegacyId, RawAlertInstance as RawAlert, } from '../types'; -import { validateRuleTypeParams, ruleExecutionStatusFromRaw, getRuleNotifyWhenType } from '../lib'; +import { + validateRuleTypeParams, + ruleExecutionStatusFromRaw, + getRuleNotifyWhenType, + validateMutatedRuleTypeParams, + convertRuleIdsToKueryNode, +} from '../lib'; import { taskInstanceToAlertTaskInstance } from '../task_runner/alert_task_instance'; import { RegistryRuleType, UntypedNormalizedRuleType } from '../rule_type_registry'; import { @@ -69,9 +78,14 @@ import { alertSummaryFromEventLog } from '../lib/alert_summary_from_event_log'; import { parseDuration } from '../../common/parse_duration'; import { retryIfConflicts } from '../lib/retry_if_conflicts'; import { partiallyUpdateAlert } from '../saved_objects'; -import { markApiKeyForInvalidation } from '../invalidate_pending_api_keys/mark_api_key_for_invalidation'; +import { bulkMarkApiKeysForInvalidation } from '../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation'; import { ruleAuditEvent, RuleAuditAction } from './audit_events'; -import { mapSortField, validateOperationOnAttributes } from './lib'; +import { + mapSortField, + validateOperationOnAttributes, + retryIfBulkEditConflicts, + applyBulkEditOperation, +} from './lib'; import { getRuleExecutionStatusPending } from '../lib/rule_execution_status'; import { Alert } from '../alert'; import { EVENT_LOG_ACTIONS } from '../plugin'; @@ -133,6 +147,21 @@ export interface RuleAggregation { doc_count: number; }>; }; + tags: { + buckets: Array<{ + key: string; + doc_count: number; + }>; + }; +} + +export interface RuleBulkEditAggregation { + alertTypeId: { + buckets: Array<{ + key: string[]; + doc_count: number; + }>; + }; } export interface ConstructorOptions { @@ -180,6 +209,63 @@ export interface FindOptions extends IndexType { filter?: string; } +export type BulkEditFields = keyof Pick; + +export type BulkEditOperation = + | { + operation: 'add' | 'delete' | 'set'; + field: Extract; + value: string[]; + } + | { + operation: 'add' | 'set'; + field: Extract; + value: NormalizedAlertAction[]; + }; + +// schedule, throttle, notifyWhen is commented out before https://github.com/elastic/kibana/issues/124850 will be implemented +// | { +// operation: 'set'; +// field: Extract; +// value: Rule['schedule']; +// } +// | { +// operation: 'set'; +// field: Extract; +// value: Rule['throttle']; +// } +// | { +// operation: 'set'; +// field: Extract; +// value: Rule['notifyWhen']; +// }; + +type RuleParamsModifier = (params: Params) => Promise; + +export interface BulkEditOptionsFilter { + filter?: string | KueryNode; + operations: BulkEditOperation[]; + paramsModifier?: RuleParamsModifier; +} + +export interface BulkEditOptionsIds { + ids: string[]; + operations: BulkEditOperation[]; + paramsModifier?: RuleParamsModifier; +} + +export type BulkEditOptions = + | BulkEditOptionsFilter + | BulkEditOptionsIds; + +export interface BulkEditError { + message: string; + rule: { + id: string; + name: string; + }; +} + export interface AggregateOptions extends IndexType { search?: string; defaultSearchOperator?: 'AND' | 'OR'; @@ -200,6 +286,7 @@ export interface AggregateResult { ruleEnabledStatus?: { enabled: number; disabled: number }; ruleMutedStatus?: { muted: number; unmuted: number }; ruleSnoozedStatus?: { snoozed: number }; + ruleTags?: string[]; } export interface FindResult { @@ -274,6 +361,10 @@ const extractedSavedObjectParamReferenceNamePrefix = 'param:'; // NOTE: Changing this prefix will require a migration to update the prefix in all existing `rule` saved objects const preconfiguredConnectorActionRefPrefix = 'preconfigured:'; +const MAX_RULES_NUMBER_FOR_BULK_EDIT = 10000; +const API_KEY_GENERATE_CONCURRENCY = 50; +const RULE_TYPE_CHECKS_CONCURRENCY = 50; + const alertingAuthorizationFilterOpts: AlertingAuthorizationFilterOpts = { type: AlertingAuthorizationFilterType.KQL, fieldNames: { ruleTypeId: 'alert.attributes.alertTypeId', consumer: 'alert.attributes.consumer' }, @@ -449,11 +540,12 @@ export class RulesClient { ); } catch (e) { // Avoid unused API key - markApiKeyForInvalidation( - { apiKey: rawRule.apiKey }, + await bulkMarkApiKeysForInvalidation( + { apiKeys: rawRule.apiKey ? [rawRule.apiKey] : [] }, this.logger, this.unsecuredSavedObjectsClient ); + throw e; } if (data.enabled) { @@ -921,6 +1013,9 @@ export class RulesClient { muted: { terms: { field: 'alert.attributes.muteAll' }, }, + tags: { + terms: { field: 'alert.attributes.tags', order: { _key: 'asc' } }, + }, snoozed: { date_range: { field: 'alert.attributes.snoozeEndTime', @@ -990,6 +1085,9 @@ export class RulesClient { snoozed: snoozedBuckets.reduce((acc, bucket) => acc + bucket.doc_count, 0), }; + const tagsBuckets = resp.aggregations.tags?.buckets || []; + ret.ruleTags = tagsBuckets.map((bucket) => bucket.key); + return ret; } @@ -1056,8 +1154,8 @@ export class RulesClient { await Promise.all([ taskIdToRemove ? this.taskManager.removeIfExists(taskIdToRemove) : null, apiKeyToInvalidate - ? markApiKeyForInvalidation( - { apiKey: apiKeyToInvalidate }, + ? bulkMarkApiKeysForInvalidation( + { apiKeys: [apiKeyToInvalidate] }, this.logger, this.unsecuredSavedObjectsClient ) @@ -1133,8 +1231,8 @@ export class RulesClient { await Promise.all([ alertSavedObject.attributes.apiKey - ? markApiKeyForInvalidation( - { apiKey: alertSavedObject.attributes.apiKey }, + ? bulkMarkApiKeysForInvalidation( + { apiKeys: [alertSavedObject.attributes.apiKey] }, this.logger, this.unsecuredSavedObjectsClient ) @@ -1233,11 +1331,12 @@ export class RulesClient { ); } catch (e) { // Avoid unused API key - markApiKeyForInvalidation( - { apiKey: createAttributes.apiKey }, + await bulkMarkApiKeysForInvalidation( + { apiKeys: createAttributes.apiKey ? [createAttributes.apiKey] : [] }, this.logger, this.unsecuredSavedObjectsClient ); + throw e; } @@ -1258,6 +1357,349 @@ export class RulesClient { ); } + public async bulkEdit( + options: BulkEditOptions + ): Promise<{ + rules: Array>; + errors: BulkEditError[]; + total: number; + }> { + const queryFilter = (options as BulkEditOptionsFilter).filter; + const ids = (options as BulkEditOptionsIds).ids; + + if (ids && queryFilter) { + throw Boom.badRequest( + "Both 'filter' and 'ids' are supplied. Define either 'ids' or 'filter' properties in method arguments" + ); + } + + let qNodeQueryFilter: null | KueryNode; + if (!queryFilter) { + qNodeQueryFilter = null; + } else if (typeof queryFilter === 'string') { + qNodeQueryFilter = fromKueryExpression(queryFilter); + } else { + qNodeQueryFilter = queryFilter; + } + + const qNodeFilter = ids ? convertRuleIdsToKueryNode(ids) : qNodeQueryFilter; + let authorizationTuple; + try { + authorizationTuple = await this.authorization.getFindAuthorizationFilter( + AlertingAuthorizationEntity.Rule, + alertingAuthorizationFilterOpts + ); + } catch (error) { + this.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.BULK_EDIT, + error, + }) + ); + throw error; + } + const { filter: authorizationFilter } = authorizationTuple; + const qNodeFilterWithAuth = + authorizationFilter && qNodeFilter + ? nodeBuilder.and([qNodeFilter, authorizationFilter as KueryNode]) + : qNodeFilter; + + const { aggregations, total } = await this.unsecuredSavedObjectsClient.find< + RawRule, + RuleBulkEditAggregation + >({ + filter: qNodeFilterWithAuth, + page: 1, + perPage: 0, + type: 'alert', + aggs: { + alertTypeId: { + multi_terms: { + terms: [ + { field: 'alert.attributes.alertTypeId' }, + { field: 'alert.attributes.consumer' }, + ], + }, + }, + }, + }); + + if (total > MAX_RULES_NUMBER_FOR_BULK_EDIT) { + throw Boom.badRequest( + `More than ${MAX_RULES_NUMBER_FOR_BULK_EDIT} rules matched for bulk edit` + ); + } + const buckets = aggregations?.alertTypeId.buckets; + + if (buckets === undefined) { + throw Error('No rules found for bulk edit'); + } + + await pMap( + buckets, + async ({ key: [ruleType, consumer] }) => { + this.ruleTypeRegistry.ensureRuleTypeEnabled(ruleType); + + try { + await this.authorization.ensureAuthorized({ + ruleTypeId: ruleType, + consumer, + operation: WriteOperations.BulkEdit, + entity: AlertingAuthorizationEntity.Rule, + }); + } catch (error) { + this.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.BULK_EDIT, + error, + }) + ); + throw error; + } + }, + { concurrency: RULE_TYPE_CHECKS_CONCURRENCY } + ); + + const { apiKeysToInvalidate, results, errors } = await retryIfBulkEditConflicts( + this.logger, + `rulesClient.update('operations=${JSON.stringify(options.operations)}, paramsModifier=${ + options.paramsModifier ? '[Function]' : undefined + }')`, + (filterKueryNode: KueryNode | null) => + this.bulkEditOcc({ + filter: filterKueryNode, + operations: options.operations, + paramsModifier: options.paramsModifier, + }), + qNodeFilterWithAuth + ); + + await bulkMarkApiKeysForInvalidation( + { apiKeys: apiKeysToInvalidate }, + this.logger, + this.unsecuredSavedObjectsClient + ); + + const updatedRules = results.map(({ id, attributes, references }) => { + return this.getAlertFromRaw( + id, + attributes.alertTypeId as string, + attributes as RawRule, + references, + false + ); + }); + + return { rules: updatedRules, errors, total }; + } + + private async bulkEditOcc({ + filter, + operations, + paramsModifier, + }: { + filter: KueryNode | null; + operations: BulkEditOptions['operations']; + paramsModifier: BulkEditOptions['paramsModifier']; + }): Promise<{ + apiKeysToInvalidate: string[]; + rules: Array>; + resultSavedObjects: Array>; + errors: BulkEditError[]; + }> { + const rulesFinder = + await this.encryptedSavedObjectsClient.createPointInTimeFinderDecryptedAsInternalUser( + { + filter, + type: 'alert', + perPage: 100, + ...(this.namespace ? { namespaces: [this.namespace] } : undefined), + } + ); + + const rules: Array> = []; + const errors: BulkEditError[] = []; + const apiKeysToInvalidate: string[] = []; + const apiKeysMap = new Map(); + const username = await this.getUserName(); + + for await (const response of rulesFinder.find()) { + await pMap( + response.saved_objects, + async (rule) => { + try { + if (rule.attributes.apiKey) { + apiKeysMap.set(rule.id, { oldApiKey: rule.attributes.apiKey }); + } + + const ruleType = this.ruleTypeRegistry.get(rule.attributes.alertTypeId); + + let attributes = cloneDeep(rule.attributes); + let ruleActions = { + actions: this.injectReferencesIntoActions( + rule.id, + rule.attributes.actions, + rule.references || [] + ), + }; + for (const operation of operations) { + switch (operation.field) { + case 'actions': + await this.validateActions(ruleType, operation.value); + ruleActions = applyBulkEditOperation(operation, ruleActions); + break; + default: + attributes = applyBulkEditOperation(operation, attributes); + } + } + + // validate schedule interval + if (attributes.schedule.interval) { + const isIntervalInvalid = + parseDuration(attributes.schedule.interval as string) < + this.minimumScheduleIntervalInMs; + if (isIntervalInvalid && this.minimumScheduleInterval.enforce) { + throw Error( + `Error updating rule: the interval is less than the allowed minimum interval of ${this.minimumScheduleInterval.value}` + ); + } else if (isIntervalInvalid && !this.minimumScheduleInterval.enforce) { + this.logger.warn( + `Rule schedule interval (${attributes.schedule.interval}) for "${ruleType.id}" rule type with ID "${attributes.id}" is less than the minimum value (${this.minimumScheduleInterval.value}). Running rules at this interval may impact alerting performance. Set "xpack.alerting.rules.minimumScheduleInterval.enforce" to true to prevent such changes.` + ); + } + } + + const ruleParams = paramsModifier + ? await paramsModifier(attributes.params as Params) + : attributes.params; + + // validate rule params + const validatedAlertTypeParams = validateRuleTypeParams( + ruleParams, + ruleType.validate?.params + ); + const validatedMutatedAlertTypeParams = validateMutatedRuleTypeParams( + validatedAlertTypeParams, + rule.attributes.params, + ruleType.validate?.params + ); + + const { + actions: rawAlertActions, + references, + params: updatedParams, + } = await this.extractReferences( + ruleType, + ruleActions.actions, + validatedMutatedAlertTypeParams + ); + + // create API key + let createdAPIKey = null; + try { + createdAPIKey = attributes.enabled + ? await this.createAPIKey(this.generateAPIKeyName(ruleType.id, attributes.name)) + : null; + } catch (error) { + throw Error(`Error updating rule: could not create API key - ${error.message}`); + } + + const apiKeyAttributes = this.apiKeyAsAlertAttributes(createdAPIKey, username); + + // collect generated API keys + if (apiKeyAttributes.apiKey) { + apiKeysMap.set(rule.id, { + ...apiKeysMap.get(rule.id), + newApiKey: apiKeyAttributes.apiKey, + }); + } + + // get notifyWhen + const notifyWhen = getRuleNotifyWhenType( + attributes.notifyWhen, + attributes.throttle ?? null + ); + + const updatedAttributes = this.updateMeta({ + ...attributes, + ...apiKeyAttributes, + params: updatedParams as RawRule['params'], + actions: rawAlertActions, + notifyWhen, + updatedBy: username, + updatedAt: new Date().toISOString(), + }); + + // add mapped_params + const mappedParams = getMappedParams(updatedParams); + + if (Object.keys(mappedParams).length) { + updatedAttributes.mapped_params = mappedParams; + } + + rules.push({ + ...rule, + references, + attributes: updatedAttributes, + }); + } catch (error) { + errors.push({ + message: error.message, + rule: { + id: rule.id, + name: rule.attributes?.name, + }, + }); + this.auditLogger?.log( + ruleAuditEvent({ + action: RuleAuditAction.BULK_EDIT, + error, + }) + ); + } + }, + { concurrency: API_KEY_GENERATE_CONCURRENCY } + ); + } + + let result; + try { + result = await this.unsecuredSavedObjectsClient.bulkUpdate(rules); + } catch (e) { + // avoid unused newly generated API keys + if (apiKeysMap.size > 0) { + await bulkMarkApiKeysForInvalidation( + { + apiKeys: Array.from(apiKeysMap.values()).reduce((acc, value) => { + if (value.newApiKey) { + acc.push(value.newApiKey); + } + return acc; + }, []), + }, + this.logger, + this.unsecuredSavedObjectsClient + ); + } + throw e; + } + + result.saved_objects.map(({ id, error }) => { + const oldApiKey = apiKeysMap.get(id)?.oldApiKey; + const newApiKey = apiKeysMap.get(id)?.newApiKey; + + // if SO wasn't saved and has new API key it will be invalidated + if (error && newApiKey) { + apiKeysToInvalidate.push(newApiKey); + // if SO saved and has old Api Key it will be invalidate + } else if (!error && oldApiKey) { + apiKeysToInvalidate.push(oldApiKey); + } + }); + + return { apiKeysToInvalidate, resultSavedObjects: result.saved_objects, errors, rules }; + } + private apiKeyAsAlertAttributes( apiKey: CreateAPIKeyResult | null, username: string | null @@ -1360,8 +1802,8 @@ export class RulesClient { await this.unsecuredSavedObjectsClient.update('alert', id, updateAttributes, { version }); } catch (e) { // Avoid unused API key - markApiKeyForInvalidation( - { apiKey: updateAttributes.apiKey }, + await bulkMarkApiKeysForInvalidation( + { apiKeys: updateAttributes.apiKey ? [updateAttributes.apiKey] : [] }, this.logger, this.unsecuredSavedObjectsClient ); @@ -1369,8 +1811,8 @@ export class RulesClient { } if (apiKeyToInvalidate) { - await markApiKeyForInvalidation( - { apiKey: apiKeyToInvalidate }, + await bulkMarkApiKeysForInvalidation( + { apiKeys: [apiKeyToInvalidate] }, this.logger, this.unsecuredSavedObjectsClient ); @@ -1471,8 +1913,8 @@ export class RulesClient { await this.unsecuredSavedObjectsClient.update('alert', id, updateAttributes, { version }); } catch (e) { // Avoid unused API key - markApiKeyForInvalidation( - { apiKey: updateAttributes.apiKey }, + await bulkMarkApiKeysForInvalidation( + { apiKeys: updateAttributes.apiKey ? [updateAttributes.apiKey] : [] }, this.logger, this.unsecuredSavedObjectsClient ); @@ -1489,8 +1931,8 @@ export class RulesClient { scheduledTaskId: scheduledTask.id, }); if (apiKeyToInvalidate) { - await markApiKeyForInvalidation( - { apiKey: apiKeyToInvalidate }, + await bulkMarkApiKeysForInvalidation( + { apiKeys: [apiKeyToInvalidate] }, this.logger, this.unsecuredSavedObjectsClient ); @@ -1629,8 +2071,8 @@ export class RulesClient { ? this.taskManager.removeIfExists(attributes.scheduledTaskId) : null, apiKeyToInvalidate - ? await markApiKeyForInvalidation( - { apiKey: apiKeyToInvalidate }, + ? await bulkMarkApiKeysForInvalidation( + { apiKeys: [apiKeyToInvalidate] }, this.logger, this.unsecuredSavedObjectsClient ) 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 b74059e4be3d66a..1a3d203162bd613 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 @@ -112,6 +112,22 @@ describe('aggregate()', () => { }, ], }, + tags: { + buckets: [ + { + key: 'a', + doc_count: 10, + }, + { + key: 'b', + doc_count: 20, + }, + { + key: 'c', + doc_count: 30, + }, + ], + }, }, }); @@ -160,6 +176,11 @@ describe('aggregate()', () => { "ruleSnoozedStatus": Object { "snoozed": 2, }, + "ruleTags": Array [ + "a", + "b", + "c", + ], } `); expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledTimes(1); @@ -187,6 +208,9 @@ describe('aggregate()', () => { ranges: [{ from: 'now' }], }, }, + tags: { + terms: { field: 'alert.attributes.tags', order: { _key: 'asc' } }, + }, }, }, ]); @@ -221,6 +245,9 @@ describe('aggregate()', () => { ranges: [{ from: 'now' }], }, }, + tags: { + terms: { field: 'alert.attributes.tags', order: { _key: 'asc' } }, + }, }, }, ]); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/bulk_edit.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/bulk_edit.test.ts new file mode 100644 index 000000000000000..e878fd3f79e1761 --- /dev/null +++ b/x-pack/plugins/alerting/server/rules_client/tests/bulk_edit.test.ts @@ -0,0 +1,902 @@ +/* + * 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 { RulesClient, ConstructorOptions } from '../rules_client'; +import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; +import { ruleTypeRegistryMock } from '../../rule_type_registry.mock'; +import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock'; +import { RecoveredActionGroup, RuleTypeParams } from '../../../common'; +import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; +import { AlertingAuthorization } from '../../authorization/alerting_authorization'; +import { ActionsAuthorization, ActionsClient } from '@kbn/actions-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(), +})); + +const taskManager = taskManagerMock.createStart(); +const ruleTypeRegistry = ruleTypeRegistryMock.create(); +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); +const authorization = alertingAuthorizationMock.create(); +const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditLoggerMock.create(); + +const kibanaVersion = 'v8.2.0'; +const createAPIKeyMock = jest.fn(); +const rulesClientParams: jest.Mocked = { + taskManager, + ruleTypeRegistry, + unsecuredSavedObjectsClient, + authorization: authorization as unknown as AlertingAuthorization, + actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization, + spaceId: 'default', + namespace: 'default', + getUserName: jest.fn(), + createAPIKey: createAPIKeyMock, + logger: loggingSystemMock.create().get(), + encryptedSavedObjectsClient: encryptedSavedObjects, + getActionsClient: jest.fn(), + getEventLogClient: jest.fn(), + kibanaVersion, + auditLogger, + minimumScheduleInterval: { value: '1m', enforce: false }, +}; + +beforeEach(() => { + getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); +}); + +setGlobalDate(); + +describe('bulkEdit()', () => { + let rulesClient: RulesClient; + let actionsClient: jest.Mocked; + const existingRule = { + id: '1', + type: 'alert', + attributes: { + enabled: false, + tags: ['foo'], + alertTypeId: 'myType', + schedule: { interval: '1m' }, + consumer: 'myApp', + scheduledTaskId: 'task-123', + params: {}, + throttle: null, + notifyWhen: null, + actions: [], + name: 'my rule name', + }, + references: [], + version: '123', + }; + const existingDecryptedRule = { + ...existingRule, + attributes: { + ...existingRule.attributes, + apiKey: Buffer.from('123:abc').toString('base64'), + }, + }; + + const mockCreatePointInTimeFinderAsInternalUser = ( + response = { saved_objects: [existingDecryptedRule] } + ) => { + encryptedSavedObjects.createPointInTimeFinderDecryptedAsInternalUser = jest + .fn() + .mockResolvedValue({ + close: jest.fn(), + find: function* asyncGenerator() { + yield response; + }, + }); + }; + + beforeEach(async () => { + rulesClient = new RulesClient(rulesClientParams); + rulesClientParams.getActionsClient.mockResolvedValue(actionsClient); + authorization.getFindAuthorizationFilter.mockResolvedValue({ + ensureRuleTypeIsAuthorized() {}, + }); + + unsecuredSavedObjectsClient.find.mockResolvedValue({ + aggregations: { + alertTypeId: { + buckets: [{ key: ['myType', 'myApp'], key_as_string: 'myType|myApp', doc_count: 1 }], + }, + }, + saved_objects: [], + per_page: 0, + page: 0, + total: 1, + }); + + mockCreatePointInTimeFinderAsInternalUser(); + + unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({ + saved_objects: [existingRule], + }); + + ruleTypeRegistry.get.mockReturnValue({ + id: 'myType', + name: 'Test', + actionGroups: [ + { id: 'default', name: 'Default' }, + { id: 'custom', name: 'Not the Default' }, + ], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: RecoveredActionGroup, + async executor() {}, + producer: 'alerts', + }); + }); + describe('tags operations', () => { + test('should add new tag', async () => { + unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({ + saved_objects: [ + { + id: '1', + type: 'alert', + attributes: { + enabled: true, + tags: ['foo', 'test-1'], + alertTypeId: 'myType', + schedule: { interval: '1m' }, + consumer: 'myApp', + scheduledTaskId: 'task-123', + params: {}, + throttle: null, + notifyWhen: null, + actions: [], + }, + references: [], + version: '123', + }, + ], + }); + + const result = await rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }); + + expect(result.total).toBe(1); + expect(result.errors).toHaveLength(0); + expect(result.rules).toHaveLength(1); + expect(result.rules[0]).toHaveProperty('tags', ['foo', 'test-1']); + + expect(unsecuredSavedObjectsClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0]).toHaveLength(1); + expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0][0]).toEqual([ + expect.objectContaining({ + id: '1', + type: 'alert', + attributes: expect.objectContaining({ + tags: ['foo', 'test-1'], + }), + }), + ]); + }); + + test('should delete tag', async () => { + unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({ + saved_objects: [ + { + id: '1', + type: 'alert', + attributes: { + enabled: true, + tags: [], + alertTypeId: 'myType', + schedule: { interval: '1m' }, + consumer: 'myApp', + scheduledTaskId: 'task-123', + params: {}, + throttle: null, + notifyWhen: null, + actions: [], + }, + references: [], + version: '123', + }, + ], + }); + + const result = await rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'tags', + operation: 'delete', + value: ['foo'], + }, + ], + }); + + expect(result.rules[0]).toHaveProperty('tags', []); + + expect(unsecuredSavedObjectsClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0]).toHaveLength(1); + expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0][0]).toEqual([ + expect.objectContaining({ + id: '1', + type: 'alert', + attributes: expect.objectContaining({ + tags: [], + }), + }), + ]); + }); + + test('should set tags', async () => { + unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({ + saved_objects: [ + { + id: '1', + type: 'alert', + attributes: { + enabled: true, + tags: ['test-1', 'test-2'], + alertTypeId: 'myType', + schedule: { interval: '1m' }, + consumer: 'myApp', + scheduledTaskId: 'task-123', + params: {}, + throttle: null, + notifyWhen: null, + actions: [], + }, + references: [], + version: '123', + }, + ], + }); + + const result = await rulesClient.bulkEdit({ + filter: '', + operations: [ + { + field: 'tags', + operation: 'set', + value: ['test-1', 'test-2'], + }, + ], + }); + + expect(result.rules[0]).toHaveProperty('tags', ['test-1', 'test-2']); + + expect(unsecuredSavedObjectsClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0]).toHaveLength(1); + expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0][0]).toEqual([ + expect.objectContaining({ + id: '1', + type: 'alert', + attributes: expect.objectContaining({ + tags: ['test-1', 'test-2'], + }), + }), + ]); + }); + }); + + describe('ruleTypes aggregation and validation', () => { + test('should call unsecuredSavedObjectsClient.find for aggregations by alertTypeId and consumer', async () => { + await rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }); + + expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledWith({ + aggs: { + alertTypeId: { + multi_terms: { + terms: [ + { + field: 'alert.attributes.alertTypeId', + }, + { + field: 'alert.attributes.consumer', + }, + ], + }, + }, + }, + filter: { + arguments: [ + { + type: 'literal', + value: 'alert.attributes.tags', + }, + { + type: 'literal', + value: 'APM', + }, + { + type: 'literal', + value: true, + }, + ], + function: 'is', + type: 'function', + }, + page: 1, + perPage: 0, + type: 'alert', + }); + }); + test('should call unsecuredSavedObjectsClient.find for aggregations when called with ids options', async () => { + await rulesClient.bulkEdit({ + ids: ['2', '3'], + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }); + + expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledWith({ + aggs: { + alertTypeId: { + multi_terms: { + terms: [ + { + field: 'alert.attributes.alertTypeId', + }, + { + field: 'alert.attributes.consumer', + }, + ], + }, + }, + }, + filter: { + arguments: [ + { + arguments: [ + { + type: 'literal', + value: 'alert.id', + }, + { + type: 'literal', + value: 'alert:2', + }, + { + type: 'literal', + value: false, + }, + ], + function: 'is', + type: 'function', + }, + { + arguments: [ + { + type: 'literal', + value: 'alert.id', + }, + { + type: 'literal', + value: 'alert:3', + }, + { + type: 'literal', + value: false, + }, + ], + function: 'is', + type: 'function', + }, + ], + function: 'or', + type: 'function', + }, + page: 1, + perPage: 0, + type: 'alert', + }); + }); + test('should throw if number of matched rules greater than 10_000', async () => { + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + aggregations: { + alertTypeId: { + buckets: [{ key: ['myType', 'myApp'], key_as_string: 'myType|myApp', doc_count: 1 }], + }, + }, + saved_objects: [], + per_page: 0, + page: 0, + total: 10001, + }); + + await expect( + rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }) + ).rejects.toThrow('More than 10000 rules matched for bulk edit'); + }); + + test('should throw if aggregations result is invalid', async () => { + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + aggregations: { + alertTypeId: {}, + }, + saved_objects: [], + per_page: 0, + page: 0, + total: 0, + }); + + await expect( + rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }) + ).rejects.toThrow('No rules found for bulk edit'); + }); + + test('should throw if ruleType is not enabled', async () => { + ruleTypeRegistry.ensureRuleTypeEnabled.mockImplementation(() => { + throw new Error('Not enabled'); + }); + + await expect( + rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }) + ).rejects.toThrow('Not enabled'); + + expect(ruleTypeRegistry.ensureRuleTypeEnabled).toHaveBeenLastCalledWith('myType'); + }); + + test('should throw if ruleType is not authorized', async () => { + authorization.ensureAuthorized.mockImplementation(() => { + throw new Error('Unauthorized'); + }); + + await expect( + rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }) + ).rejects.toThrow('Unauthorized'); + + expect(authorization.ensureAuthorized).toHaveBeenLastCalledWith({ + consumer: 'myApp', + entity: 'rule', + operation: 'bulkEdit', + ruleTypeId: 'myType', + }); + }); + }); + + describe('apiKeys', () => { + test('should call createPointInTimeFinderDecryptedAsInternalUser that returns api Keys', async () => { + await rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }); + + expect( + encryptedSavedObjects.createPointInTimeFinderDecryptedAsInternalUser + ).toHaveBeenCalledWith({ + filter: { + arguments: [ + { + type: 'literal', + value: 'alert.attributes.tags', + }, + { + type: 'literal', + value: 'APM', + }, + { + type: 'literal', + value: true, + }, + ], + function: 'is', + type: 'function', + }, + perPage: 100, + type: 'alert', + namespaces: ['default'], + }); + }); + + test('should call bulkMarkApiKeysForInvalidation with keys apiKeys to invalidate', async () => { + await rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }); + + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MTIzOmFiYw=='] }, + expect.any(Object), + expect.any(Object) + ); + }); + + test('should call bulkMarkApiKeysForInvalidation to invalidate unused keys if bulkUpdate failed', async () => { + createAPIKeyMock.mockReturnValue({ apiKeysEnabled: true, result: { api_key: '111' } }); + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [ + { + ...existingDecryptedRule, + attributes: { ...existingDecryptedRule.attributes, enabled: true }, + }, + ], + }); + + unsecuredSavedObjectsClient.bulkUpdate.mockImplementation(() => { + throw new Error('Fail'); + }); + + await expect( + rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }) + ).rejects.toThrow('Fail'); + + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['dW5kZWZpbmVkOjExMQ=='] }, + expect.any(Object), + expect.any(Object) + ); + }); + + test('should call bulkMarkApiKeysForInvalidation to invalidate unused keys if SO update failed', async () => { + createAPIKeyMock.mockReturnValue({ apiKeysEnabled: true, result: { api_key: '111' } }); + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [ + { + ...existingDecryptedRule, + attributes: { ...existingDecryptedRule.attributes, enabled: true }, + }, + ], + }); + + unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({ + saved_objects: [ + { + id: '1', + type: 'alert', + attributes: { + enabled: true, + tags: ['foo'], + alertTypeId: 'myType', + schedule: { interval: '1m' }, + consumer: 'myApp', + scheduledTaskId: 'task-123', + params: { index: ['test-index-*'] }, + throttle: null, + notifyWhen: null, + actions: [], + }, + references: [], + version: '123', + error: { + error: 'test failure', + statusCode: 500, + message: 'test failure', + }, + }, + ], + }); + + await rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }); + + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['dW5kZWZpbmVkOjExMQ=='] }, + expect.any(Object), + expect.any(Object) + ); + }); + + test('should not call create apiKey if rule is disabled', async () => { + await rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }); + expect(rulesClientParams.createAPIKey).not.toHaveBeenCalledWith(); + }); + + test('should return error in rule errors if key is not generated', async () => { + mockCreatePointInTimeFinderAsInternalUser({ + saved_objects: [ + { + ...existingDecryptedRule, + attributes: { ...existingDecryptedRule.attributes, enabled: true }, + }, + ], + }); + + await rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }); + expect(rulesClientParams.createAPIKey).toHaveBeenCalledWith('Alerting: myType/my rule name'); + }); + }); + + describe('params validation', () => { + test('should return error for rule that failed params validation', async () => { + ruleTypeRegistry.get.mockReturnValue({ + id: '123', + name: 'Test', + actionGroups: [{ id: 'default', name: 'Default' }], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: RecoveredActionGroup, + validate: { + params: schema.object({ + param1: schema.string(), + }), + }, + async executor() {}, + producer: 'alerts', + }); + + const result = await rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }); + + expect(result.errors).toHaveLength(1); + + expect(result.errors[0]).toHaveProperty( + 'message', + 'params invalid: [param1]: expected value of type [string] but got [undefined]' + ); + expect(result.errors[0]).toHaveProperty('rule.id', '1'); + expect(result.errors[0]).toHaveProperty('rule.name', 'my rule name'); + }); + + test('should validate mutatedParams for rules', async () => { + ruleTypeRegistry.get.mockReturnValue({ + id: '123', + name: 'Test', + actionGroups: [{ id: 'default', name: 'Default' }], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: RecoveredActionGroup, + validate: { + params: { + validate: (rule) => rule as RuleTypeParams, + validateMutatedParams: (rule: unknown) => { + throw Error('Mutated error for rule'); + }, + }, + }, + async executor() {}, + producer: 'alerts', + }); + + const result = await rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }); + + expect(result.errors).toHaveLength(1); + + expect(result.errors[0]).toHaveProperty( + 'message', + 'Mutated params invalid: Mutated error for rule' + ); + expect(result.errors[0]).toHaveProperty('rule.id', '1'); + expect(result.errors[0]).toHaveProperty('rule.name', 'my rule name'); + }); + }); + + describe('attributes validation', () => { + test('should not update saved object and return error if SO has interval less than minimum configured one when enforce = true', async () => { + rulesClient = new RulesClient({ + ...rulesClientParams, + minimumScheduleInterval: { value: '3m', enforce: true }, + }); + + unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({ + saved_objects: [], + }); + + const result = await rulesClient.bulkEdit({ + filter: '', + operations: [], + paramsModifier: async (params) => { + params.index = ['test-index-*']; + + return params; + }, + }); + + expect(result.errors).toHaveLength(1); + expect(result.rules).toHaveLength(0); + expect(result.errors[0].message).toBe( + 'Error updating rule: the interval is less than the allowed minimum interval of 3m' + ); + }); + }); + + describe('paramsModifier', () => { + test('should update index pattern params', async () => { + unsecuredSavedObjectsClient.bulkUpdate.mockResolvedValue({ + saved_objects: [ + { + id: '1', + type: 'alert', + attributes: { + enabled: true, + tags: ['foo'], + alertTypeId: 'myType', + schedule: { interval: '1m' }, + consumer: 'myApp', + scheduledTaskId: 'task-123', + params: { index: ['test-index-*'] }, + throttle: null, + notifyWhen: null, + actions: [], + }, + references: [], + version: '123', + }, + ], + }); + + const result = await rulesClient.bulkEdit({ + filter: '', + operations: [], + paramsModifier: async (params) => { + params.index = ['test-index-*']; + + return params; + }, + }); + + expect(result.errors).toHaveLength(0); + expect(result.rules).toHaveLength(1); + expect(result.rules[0]).toHaveProperty('params.index', ['test-index-*']); + + expect(unsecuredSavedObjectsClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0]).toHaveLength(1); + expect(unsecuredSavedObjectsClient.bulkUpdate.mock.calls[0][0]).toEqual([ + expect.objectContaining({ + id: '1', + type: 'alert', + attributes: expect.objectContaining({ + params: expect.objectContaining({ + index: ['test-index-*'], + }), + }), + }), + ]); + }); + }); + + describe('method input validation', () => { + test('should throw error when both ids and filter supplied in method call', async () => { + await expect( + rulesClient.bulkEdit({ + filter: 'alert.attributes.tags: "APM"', + ids: ['1', '2'], + operations: [ + { + field: 'tags', + operation: 'add', + value: ['test-1'], + }, + ], + }) + ).rejects.toThrow( + "Both 'filter' and 'ids' are supplied. Define either 'ids' or 'filter' properties in method arguments" + ); + }); + }); +}); 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 c8f10c4e686f003..8e24b7c18326289 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 @@ -20,6 +20,11 @@ import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { RecoveredActionGroup } from '../../../common'; import { getDefaultRuleMonitoring } from '../../task_runner/task_runner'; +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(), +})); jest.mock('@kbn/core/server/saved_objects/service/lib/utils', () => ({ SavedObjectsUtils: { @@ -2119,25 +2124,17 @@ describe('create()', () => { result: { id: '123', name: '123', api_key: 'abc' }, }); unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Test failure')); - const createdAt = new Date().toISOString(); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt, - }, - references: [], - }); await expect(rulesClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot( `"Test failure"` ); expect(taskManager.schedule).not.toHaveBeenCalled(); - expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(2); - expect(unsecuredSavedObjectsClient.create.mock.calls[1][1]).toStrictEqual({ - apiKeyId: '123', - createdAt, - }); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MTIzOmFiYw=='] }, + expect.any(Object), + expect.any(Object) + ); }); test('fails if task scheduling fails due to conflict', async () => { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts index f6194ec6c1a5ff6..6b45c16bcd65422 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/delete.test.ts @@ -16,6 +16,11 @@ import { AlertingAuthorization } from '../../authorization/alerting_authorizatio import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; import { getBeforeSetup } 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(), +})); const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -100,21 +105,15 @@ describe('delete()', () => { }); test('successfully removes an alert', async () => { - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); const result = await rulesClient.delete({ id: '1' }); expect(result).toEqual({ success: true }); expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledWith('alert', '1'); expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123'); - expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toBe( - 'api_key_pending_invalidation' + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MTIzOmFiYw=='] }, + expect.any(Object), + expect.any(Object) ); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', @@ -124,15 +123,6 @@ describe('delete()', () => { test('falls back to SOC.get when getDecryptedAsInternalUser throws an error', async () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValue(new Error('Fail')); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); const result = await rulesClient.delete({ id: '1' }); expect(result).toEqual({ success: true }); @@ -159,15 +149,6 @@ describe('delete()', () => { }); test(`doesn't invalidate API key when apiKey is null`, async () => { - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue({ ...existingAlert, attributes: { @@ -183,24 +164,15 @@ describe('delete()', () => { test('swallows error when invalidate API key throws', async () => { unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail')); await rulesClient.delete({ id: '1' }); - expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toBe( - 'api_key_pending_invalidation' - ); - expect(rulesClientParams.logger.error).toHaveBeenCalledWith( - 'Failed to mark for API key [id="MTIzOmFiYw=="] for invalidation: Fail' + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MTIzOmFiYw=='] }, + expect.any(Object), + expect.any(Object) ); }); test('swallows error when getDecryptedAsInternalUser throws an error', async () => { - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValue(new Error('Fail')); await rulesClient.delete({ id: '1' }); 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 f15b647a8e39679..02f2c66a491ad6e 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 @@ -14,11 +14,15 @@ import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/s import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; import { AlertingAuthorization } from '../../authorization/alerting_authorization'; import { ActionsAuthorization } from '@kbn/actions-plugin/server'; -import { InvalidatePendingApiKey } from '../../types'; 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(), +})); jest.mock('uuid', () => ({ v4: () => '5f6aa57d-3e22-484e-bae8-cbed868f4d28', @@ -188,15 +192,6 @@ describe('disable()', () => { }); test('disables an alert', async () => { - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); await rulesClient.disable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { @@ -235,21 +230,15 @@ describe('disable()', () => { } ); expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123'); - expect( - (unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId - ).toBe('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 () => { - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); const scheduledTaskId = 'task-123'; taskManager.get.mockResolvedValue({ id: scheduledTaskId, @@ -317,9 +306,12 @@ describe('disable()', () => { } ); expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123'); - expect( - (unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId - ).toBe('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({ @@ -362,15 +354,6 @@ describe('disable()', () => { }); test('disables the rule even if unable to retrieve task manager doc to generate recovery event log events', async () => { - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); taskManager.get.mockRejectedValueOnce(new Error('Fail')); await rulesClient.disable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); @@ -410,9 +393,12 @@ describe('disable()', () => { } ); expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123'); - expect( - (unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId - ).toBe('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( @@ -422,15 +408,6 @@ describe('disable()', () => { test('falls back when getDecryptedAsInternalUser throws an error', async () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail')); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); await rulesClient.disable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1'); @@ -483,16 +460,6 @@ describe('disable()', () => { }, }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); - await rulesClient.disable({ id: '1' }); expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); expect(taskManager.removeIfExists).not.toHaveBeenCalled(); @@ -500,15 +467,6 @@ describe('disable()', () => { }); test(`doesn't invalidate when no API key is used`, async () => { - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValueOnce(existingAlert); await rulesClient.disable({ id: '1' }); @@ -516,15 +474,6 @@ describe('disable()', () => { }); test('swallows error when failing to load decrypted saved object', async () => { - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail')); await rulesClient.disable({ id: '1' }); @@ -547,8 +496,11 @@ describe('disable()', () => { test('swallows error when invalidate API key throws', async () => { unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail')); await rulesClient.disable({ id: '1' }); - expect(rulesClientParams.logger.error).toHaveBeenCalledWith( - 'Failed to mark for API key [id="MTIzOmFiYw=="] for invalidation: Fail' + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MTIzOmFiYw=='] }, + expect.any(Object), + expect.any(Object) ); }); 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 0a4737006d55741..d823e0aaafdb87d 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 @@ -16,8 +16,12 @@ import { AlertingAuthorization } from '../../authorization/alerting_authorizatio 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 { InvalidatePendingApiKey } from '../../types'; 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(), +})); const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -183,7 +187,6 @@ describe('enable()', () => { }); test('enables a rule', async () => { - const createdAt = new Date().toISOString(); unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ ...existingAlert, attributes: { @@ -194,22 +197,12 @@ describe('enable()', () => { updatedBy: 'elastic', }, }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt, - }, - references: [], - }); await rulesClient.enable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); - expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith('api_key_pending_invalidation'); expect(rulesClientParams.createAPIKey).toHaveBeenCalled(); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( 'alert', @@ -273,7 +266,6 @@ describe('enable()', () => { }); test('invalidates API key if ever one existed prior to updating', async () => { - const createdAt = new Date().toISOString(); encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue({ ...existingAlert, attributes: { @@ -281,24 +273,18 @@ describe('enable()', () => { apiKey: Buffer.from('123:abc').toString('base64'), }, }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt, - }, - references: [], - }); await rulesClient.enable({ id: '1' }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); - expect( - (unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId - ).toBe('123'); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MTIzOmFiYw=='] }, + expect.any(Object), + expect.any(Object) + ); }); test(`doesn't enable already enabled alerts`, async () => { @@ -399,31 +385,24 @@ describe('enable()', () => { }); test('throws error when failing to update the first time', async () => { - const createdAt = new Date().toISOString(); rulesClientParams.createAPIKey.mockResolvedValueOnce({ apiKeysEnabled: true, result: { id: '123', name: '123', api_key: 'abc' }, }); unsecuredSavedObjectsClient.update.mockReset(); unsecuredSavedObjectsClient.update.mockRejectedValueOnce(new Error('Fail to update')); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt, - }, - references: [], - }); await expect(rulesClient.enable({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot( `"Fail to update"` ); expect(rulesClientParams.getUserName).toHaveBeenCalled(); expect(rulesClientParams.createAPIKey).toHaveBeenCalled(); - expect( - (unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId - ).toBe('123'); + 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(); }); @@ -462,7 +441,6 @@ describe('enable()', () => { }); test('enables a rule if conflict errors received when scheduling a task', async () => { - const createdAt = new Date().toISOString(); unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ ...existingAlert, attributes: { @@ -473,15 +451,6 @@ describe('enable()', () => { updatedBy: 'elastic', }, }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt, - }, - references: [], - }); taskManager.schedule.mockRejectedValueOnce( Object.assign(new Error('Conflict!'), { statusCode: 409 }) ); @@ -491,7 +460,6 @@ describe('enable()', () => { expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); - expect(unsecuredSavedObjectsClient.create).not.toBeCalledWith('api_key_pending_invalidation'); expect(rulesClientParams.createAPIKey).toHaveBeenCalled(); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( 'alert', diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts index 573ae98ba49f0af..1508d49fe58517b 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update.test.ts @@ -12,7 +12,7 @@ import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mock import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; import { ruleTypeRegistryMock } from '../../rule_type_registry.mock'; import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock'; -import { IntervalSchedule, InvalidatePendingApiKey } from '../../types'; +import { IntervalSchedule } from '../../types'; import { RecoveredActionGroup } from '../../../common'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; @@ -22,6 +22,7 @@ import { ActionsAuthorization, ActionsClient } 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('@kbn/core/server/saved_objects/service/lib/utils', () => ({ SavedObjectsUtils: { @@ -29,6 +30,11 @@ jest.mock('@kbn/core/server/saved_objects/service/lib/utils', () => ({ }, })); +jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({ + bulkMarkApiKeysForInvalidation: jest.fn(), +})); + +const bulkMarkApiKeysForInvalidationMock = bulkMarkApiKeysForInvalidation as jest.Mock; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); @@ -238,15 +244,6 @@ describe('update()', () => { }, ], }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '234', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); const result = await rulesClient.update({ id: '1', data: { @@ -331,7 +328,8 @@ describe('update()', () => { namespace: 'default', }); expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); - expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(2); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidationMock).toHaveBeenCalledTimes(1); expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(3); expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual('alert'); expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(` @@ -875,24 +873,6 @@ describe('update()', () => { }, ], }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '234', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '234', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); const result = await rulesClient.update({ id: '1', data: { @@ -942,7 +922,15 @@ describe('update()', () => { "updatedAt": 2019-02-12T21:01:22.479Z, } `); - expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(2); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidationMock).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidationMock).toHaveBeenCalledWith( + { + apiKeys: ['MTIzOmFiYw=='], + }, + expect.any(Object), + expect.any(Object) + ); expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(3); expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual('alert'); expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(` @@ -1040,15 +1028,6 @@ describe('update()', () => { }, ], }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '234', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); const result = await rulesClient.update({ id: '1', data: { @@ -1099,7 +1078,7 @@ describe('update()', () => { "updatedAt": 2019-02-12T21:01:22.479Z, } `); - expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(2); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(3); expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual('alert'); expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(` @@ -1322,7 +1301,7 @@ describe('update()', () => { }, ], }); - unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail')); // add ApiKey to invalidate + bulkMarkApiKeysForInvalidationMock.mockImplementationOnce(() => new Error('Fail')); await rulesClient.update({ id: '1', data: { @@ -1345,8 +1324,12 @@ describe('update()', () => { ], }, }); - expect(rulesClientParams.logger.error).toHaveBeenCalledWith( - 'Failed to mark for API key [id="MTIzOmFiYw=="] for invalidation: Fail' + expect(bulkMarkApiKeysForInvalidationMock).toHaveBeenCalledWith( + { + apiKeys: ['MTIzOmFiYw=='], + }, + expect.any(Object), + expect.any(Object) ); }); @@ -1516,9 +1499,14 @@ describe('update()', () => { }, }) ).rejects.toThrowErrorMatchingInlineSnapshot(`"Fail"`); - expect( - (unsecuredSavedObjectsClient.create.mock.calls[1][1] as InvalidatePendingApiKey).apiKeyId - ).toBe('234'); + expect(bulkMarkApiKeysForInvalidationMock).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidationMock).toHaveBeenCalledWith( + { + apiKeys: ['MjM0OmFiYw=='], + }, + expect.any(Object), + expect.any(Object) + ); }); describe('updating an alert schedule', () => { @@ -1913,15 +1901,6 @@ describe('update()', () => { }, ], }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '234', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); await rulesClient.update({ id: '1', data: { diff --git a/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts index abc0db48ac167b7..e2841ba4927c622 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/update_api_key.test.ts @@ -15,9 +15,14 @@ import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks'; import { AlertingAuthorization } from '../../authorization/alerting_authorization'; import { ActionsAuthorization } from '@kbn/actions-plugin/server'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; -import { InvalidatePendingApiKey } from '../../types'; 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(), +})); + +const bulkMarkApiKeysForInvalidationMock = bulkMarkApiKeysForInvalidation as jest.Mock; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); @@ -89,15 +94,6 @@ describe('updateApiKey()', () => { beforeEach(() => { rulesClient = new RulesClient(rulesClientParams); unsecuredSavedObjectsClient.get.mockResolvedValue(existingAlert); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '234', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingEncryptedAlert); }); @@ -140,8 +136,11 @@ describe('updateApiKey()', () => { }, { version: '123' } ); - expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toBe( - 'api_key_pending_invalidation' + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MTIzOmFiYw=='] }, + expect.any(Object), + expect.any(Object) ); }); @@ -162,15 +161,6 @@ describe('updateApiKey()', () => { result: { id: '234', name: '123', api_key: 'abc' }, }); encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail')); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '123', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); await rulesClient.updateApiKey({ id: '1' }); expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1'); @@ -210,33 +200,26 @@ describe('updateApiKey()', () => { }); test('swallows error when invalidate API key throws', async () => { - unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Fail')); + bulkMarkApiKeysForInvalidationMock.mockImplementationOnce(() => new Error('Fail')); await rulesClient.updateApiKey({ id: '1' }); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalled(); - expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toBe( - 'api_key_pending_invalidation' + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MTIzOmFiYw=='] }, + expect.any(Object), + expect.any(Object) ); }); test('swallows error when getting decrypted object throws', async () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail')); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '234', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); await rulesClient.updateApiKey({ id: '1' }); expect(rulesClientParams.logger.error).toHaveBeenCalledWith( 'updateApiKey(): Failed to load API key to invalidate on alert 1: Fail' ); expect(unsecuredSavedObjectsClient.update).toHaveBeenCalled(); - expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); }); test('throws when unsecuredSavedObjectsClient update fails and invalidates newly created API key', async () => { @@ -245,22 +228,16 @@ describe('updateApiKey()', () => { result: { id: '234', name: '234', api_key: 'abc' }, }); unsecuredSavedObjectsClient.update.mockRejectedValueOnce(new Error('Fail')); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: '1', - type: 'api_key_pending_invalidation', - attributes: { - apiKeyId: '234', - createdAt: '2019-02-12T21:01:22.479Z', - }, - references: [], - }); await expect(rulesClient.updateApiKey({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot( `"Fail"` ); - expect( - (unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId - ).toBe('234'); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1); + expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith( + { apiKeys: ['MjM0OmFiYw=='] }, + expect.any(Object), + expect.any(Object) + ); }); describe('authorization', () => { diff --git a/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts b/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts index 3732c79075f97ef..753015aa02ea513 100644 --- a/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts +++ b/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts @@ -116,7 +116,7 @@ async function update(success: boolean) { expect(logger.warn).lastCalledWith(`rulesClient.update('alert-id') conflict, exceeded retries`); return expectConflict(success, err, 'create'); } - expectSuccess(success, 3, 'create'); + expectSuccess(success, 2, 'create'); // only checking the debug messages in this test expect(logger.debug).nthCalledWith(1, `rulesClient.update('alert-id') conflict, retrying ...`); diff --git a/x-pack/plugins/alerting/server/saved_objects/index.ts b/x-pack/plugins/alerting/server/saved_objects/index.ts index 85e4dc5a8e05bb0..6566fee15d4a861 100644 --- a/x-pack/plugins/alerting/server/saved_objects/index.ts +++ b/x-pack/plugins/alerting/server/saved_objects/index.ts @@ -12,6 +12,7 @@ import type { SavedObjectsServiceSetup, } from '@kbn/core/server'; import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; +import { MigrateFunctionsObject } from '@kbn/kibana-utils-plugin/common'; import { alertMappings } from './mappings'; import { getMigrations } from './migrations'; import { transformRulesForExport } from './transform_rule_for_export'; @@ -51,14 +52,15 @@ export function setupSavedObjects( encryptedSavedObjects: EncryptedSavedObjectsPluginSetup, ruleTypeRegistry: RuleTypeRegistry, logger: Logger, - isPreconfigured: (connectorId: string) => boolean + isPreconfigured: (connectorId: string) => boolean, + getSearchSourceMigrations: () => MigrateFunctionsObject ) { savedObjects.registerType({ name: 'alert', hidden: true, namespaceType: 'multiple-isolated', convertToMultiNamespaceTypeVersion: '8.0.0', - migrations: getMigrations(encryptedSavedObjects, isPreconfigured), + migrations: getMigrations(encryptedSavedObjects, getSearchSourceMigrations(), isPreconfigured), mappings: alertMappings, management: { displayName: 'rule', 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 921412d4e79e853..c83d0a95dfdcb86 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.test.ts @@ -8,7 +8,7 @@ import uuid from 'uuid'; import { getMigrations, isAnyActionSupportIncidents } from './migrations'; import { RawRule } from '../types'; -import { SavedObjectUnsanitizedDoc } from '@kbn/core/server'; +import { SavedObjectMigrationContext, SavedObjectUnsanitizedDoc } from '@kbn/core/server'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; import { migrationMocks } from '@kbn/core/server/mocks'; import { RuleType, ruleTypeMappings } from '@kbn/securitysolution-rules'; @@ -25,7 +25,7 @@ describe('successful migrations', () => { }); describe('7.10.0', () => { test('marks alerts as legacy', () => { - const migration710 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.10.0']; + const migration710 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.10.0']; const alert = getMockData({}); expect(migration710(alert, migrationContext)).toMatchObject({ ...alert, @@ -39,7 +39,7 @@ describe('successful migrations', () => { }); test('migrates the consumer for metrics', () => { - const migration710 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.10.0']; + const migration710 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.10.0']; const alert = getMockData({ consumer: 'metrics', }); @@ -56,7 +56,7 @@ describe('successful migrations', () => { }); test('migrates the consumer for siem', () => { - const migration710 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.10.0']; + const migration710 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.10.0']; const alert = getMockData({ consumer: 'securitySolution', }); @@ -73,7 +73,7 @@ describe('successful migrations', () => { }); test('migrates the consumer for alerting', () => { - const migration710 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.10.0']; + const migration710 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.10.0']; const alert = getMockData({ consumer: 'alerting', }); @@ -90,7 +90,7 @@ describe('successful migrations', () => { }); test('migrates PagerDuty actions to set a default dedupkey of the AlertId', () => { - const migration710 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.10.0']; + const migration710 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.10.0']; const alert = getMockData({ actions: [ { @@ -127,7 +127,7 @@ describe('successful migrations', () => { }); test('skips PagerDuty actions with a specified dedupkey', () => { - const migration710 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.10.0']; + const migration710 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.10.0']; const alert = getMockData({ actions: [ { @@ -165,7 +165,7 @@ describe('successful migrations', () => { }); test('skips PagerDuty actions with an eventAction of "trigger"', () => { - const migration710 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.10.0']; + const migration710 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.10.0']; const alert = getMockData({ actions: [ { @@ -204,7 +204,7 @@ describe('successful migrations', () => { }); test('creates execution status', () => { - const migration710 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.10.0']; + const migration710 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.10.0']; const alert = getMockData(); const dateStart = Date.now(); const migratedAlert = migration710(alert, migrationContext); @@ -232,7 +232,7 @@ describe('successful migrations', () => { describe('7.11.0', () => { test('add updatedAt field to alert - set to SavedObject updated_at attribute', () => { - const migration711 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.0']; + const migration711 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.11.0']; const alert = getMockData({}, true); expect(migration711(alert, migrationContext)).toEqual({ ...alert, @@ -245,7 +245,7 @@ describe('successful migrations', () => { }); test('add updatedAt field to alert - set to createdAt when SavedObject updated_at is not defined', () => { - const migration711 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.0']; + const migration711 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.11.0']; const alert = getMockData({}); expect(migration711(alert, migrationContext)).toEqual({ ...alert, @@ -258,7 +258,7 @@ describe('successful migrations', () => { }); test('add notifyWhen=onActiveAlert when throttle is null', () => { - const migration711 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.0']; + const migration711 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.11.0']; const alert = getMockData({}); expect(migration711(alert, migrationContext)).toEqual({ ...alert, @@ -271,7 +271,7 @@ describe('successful migrations', () => { }); test('add notifyWhen=onActiveAlert when throttle is set', () => { - const migration711 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.0']; + const migration711 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.11.0']; const alert = getMockData({ throttle: '5m' }); expect(migration711(alert, migrationContext)).toEqual({ ...alert, @@ -286,7 +286,9 @@ describe('successful migrations', () => { describe('7.11.2', () => { test('transforms connectors that support incident correctly', () => { - const migration7112 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.2']; + const migration7112 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.11.2' + ]; const alert = getMockData({ actions: [ { @@ -428,7 +430,9 @@ describe('successful migrations', () => { }); test('it transforms only subAction=pushToService', () => { - const migration7112 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.2']; + const migration7112 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.11.2' + ]; const alert = getMockData({ actions: [ { @@ -447,7 +451,9 @@ describe('successful migrations', () => { }); test('it does not transforms other connectors', () => { - const migration7112 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.2']; + const migration7112 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.11.2' + ]; const alert = getMockData({ actions: [ { @@ -526,7 +532,9 @@ describe('successful migrations', () => { }); test('it does not transforms alerts when the right structure connectors is already applied', () => { - const migration7112 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.2']; + const migration7112 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.11.2' + ]; const alert = getMockData({ actions: [ { @@ -563,7 +571,9 @@ describe('successful migrations', () => { }); test('if incident attribute is an empty object, copy back the related attributes from subActionParams back to incident', () => { - const migration7112 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.2']; + const migration7112 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.11.2' + ]; const alert = getMockData({ actions: [ { @@ -625,7 +635,9 @@ describe('successful migrations', () => { }); test('custom action does not get migrated/loss', () => { - const migration7112 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.2']; + const migration7112 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.11.2' + ]; const alert = getMockData({ actions: [ { @@ -654,7 +666,7 @@ describe('successful migrations', () => { describe('7.13.0', () => { test('security solution alerts get migrated and remove null values', () => { - const migration713 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.13.0']; + const migration713 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.13.0']; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -748,7 +760,7 @@ describe('successful migrations', () => { }); test('non-null values in security solution alerts are not modified', () => { - const migration713 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.13.0']; + const migration713 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.13.0']; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -815,7 +827,7 @@ describe('successful migrations', () => { }); test('security solution threshold alert with string in threshold.field is migrated to array', () => { - const migration713 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.13.0']; + const migration713 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.13.0']; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -846,7 +858,7 @@ describe('successful migrations', () => { }); test('security solution threshold alert with empty string in threshold.field is migrated to empty array', () => { - const migration713 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.13.0']; + const migration713 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.13.0']; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -877,7 +889,7 @@ describe('successful migrations', () => { }); test('security solution threshold alert with array in threshold.field and cardinality is left alone', () => { - const migration713 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.13.0']; + const migration713 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.13.0']; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -919,7 +931,7 @@ describe('successful migrations', () => { }); test('security solution ML alert with string in machineLearningJobId is converted to an array', () => { - const migration713 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.13.0']; + const migration713 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.13.0']; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -945,7 +957,7 @@ describe('successful migrations', () => { }); test('security solution ML alert with an array in machineLearningJobId is preserved', () => { - const migration713 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.13.0']; + const migration713 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.13.0']; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -973,7 +985,9 @@ describe('successful migrations', () => { describe('7.14.1', () => { test('security solution author field is migrated to array if it is undefined', () => { - const migration7141 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.14.1']; + const migration7141 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.14.1' + ]; const alert = getMockData({ alertTypeId: 'siem.signals', params: {}, @@ -991,7 +1005,9 @@ describe('successful migrations', () => { }); test('security solution author field does not override existing values if they exist', () => { - const migration7141 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.14.1']; + const migration7141 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.14.1' + ]; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -1015,7 +1031,9 @@ describe('successful migrations', () => { describe('7.15.0', () => { test('security solution is migrated to saved object references if it has 1 exceptionsList', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -1044,7 +1062,9 @@ describe('successful migrations', () => { }); test('security solution is migrated to saved object references if it has 2 exceptionsLists', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -1084,7 +1104,9 @@ describe('successful migrations', () => { }); test('security solution is migrated to saved object references if it has 3 exceptionsLists', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -1135,7 +1157,9 @@ describe('successful migrations', () => { }); test('security solution does not change anything if exceptionsList is missing', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = getMockData({ alertTypeId: 'siem.signals', params: { @@ -1147,7 +1171,9 @@ describe('successful migrations', () => { }); test('security solution will keep existing references if we do not have an exceptionsList but we do already have references', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.signals', @@ -1177,7 +1203,9 @@ describe('successful migrations', () => { }); test('security solution keep any foreign references if they exist but still migrate other references', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.signals', @@ -1242,7 +1270,9 @@ describe('successful migrations', () => { }); test('security solution is idempotent and if re-run on the same migrated data will keep the same items', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.signals', @@ -1282,7 +1312,9 @@ describe('successful migrations', () => { }); test('security solution will migrate with only missing data if we have partially migrated data', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.signals', @@ -1331,7 +1363,9 @@ describe('successful migrations', () => { }); test('security solution will not migrate if exception list if it is invalid data', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.signals', @@ -1345,7 +1379,9 @@ describe('successful migrations', () => { }); test('security solution will migrate valid data if it is mixed with invalid data', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.signals', @@ -1387,7 +1423,9 @@ describe('successful migrations', () => { }); test('security solution will not migrate if exception list is invalid data but will keep existing references', () => { - const migration7150 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.15.0']; + const migration7150 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.15.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.signals', @@ -1419,7 +1457,7 @@ describe('successful migrations', () => { describe('7.16.0', () => { test('add legacyId field to alert - set to SavedObject id attribute', () => { - const migration716 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration716 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.16.0']; const alert = getMockData({}, true); expect(migration716(alert, migrationContext)).toEqual({ ...alert, @@ -1434,7 +1472,7 @@ describe('successful migrations', () => { isPreconfigured.mockReset(); isPreconfigured.mockReturnValueOnce(true); isPreconfigured.mockReturnValueOnce(false); - const migration716 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration716 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.16.0']; const rule = { ...getMockData({ actions: [ @@ -1510,7 +1548,7 @@ describe('successful migrations', () => { isPreconfigured.mockReturnValueOnce(true); isPreconfigured.mockReturnValueOnce(false); isPreconfigured.mockReturnValueOnce(false); - const migration716 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration716 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.16.0']; const rule = { ...getMockData({ actions: [ @@ -1593,7 +1631,7 @@ describe('successful migrations', () => { test('does nothing to rules with no references', () => { isPreconfigured.mockReset(); - const migration716 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration716 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.16.0']; const rule = { ...getMockData({ actions: [ @@ -1629,7 +1667,7 @@ describe('successful migrations', () => { test('does nothing to rules with no action references', () => { isPreconfigured.mockReset(); - const migration716 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration716 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.16.0']; const rule = { ...getMockData({ actions: [ @@ -1671,7 +1709,7 @@ describe('successful migrations', () => { test('does nothing to rules with references but no actions', () => { isPreconfigured.mockReset(); - const migration716 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration716 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.16.0']; const rule = { ...getMockData({ actions: [], @@ -1699,7 +1737,9 @@ describe('successful migrations', () => { }); test('security solution is migrated to saved object references if it has a "ruleAlertId"', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = getMockData({ alertTypeId: 'siem.notifications', params: { @@ -1724,7 +1764,9 @@ describe('successful migrations', () => { }); test('security solution does not migrate anything if its type is not siem.notifications', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = getMockData({ alertTypeId: 'other-type', params: { @@ -1741,7 +1783,9 @@ describe('successful migrations', () => { }); }); test('security solution does not change anything if "ruleAlertId" is missing', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = getMockData({ alertTypeId: 'siem.notifications', params: {}, @@ -1757,7 +1801,9 @@ describe('successful migrations', () => { }); test('security solution will keep existing references if we do not have a "ruleAlertId" but we do already have references', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.notifications', @@ -1789,7 +1835,9 @@ describe('successful migrations', () => { }); test('security solution will keep any foreign references if they exist but still migrate other "ruleAlertId" references', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.notifications', @@ -1828,7 +1876,9 @@ describe('successful migrations', () => { }); test('security solution is idempotent and if re-run on the same migrated data will keep the same items "ruleAlertId" references', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.notifications', @@ -1862,7 +1912,9 @@ describe('successful migrations', () => { }); test('security solution will not migrate "ruleAlertId" if it is invalid data', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.notifications', @@ -1882,7 +1934,9 @@ describe('successful migrations', () => { }); test('security solution will not migrate "ruleAlertId" if it is invalid data but will keep existing references', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = { ...getMockData({ alertTypeId: 'siem.notifications', @@ -1916,7 +1970,9 @@ describe('successful migrations', () => { }); test('geo-containment alert migration extracts boundary and index references', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = { ...getMockData({ alertTypeId: '.geo-containment', @@ -1944,7 +2000,9 @@ describe('successful migrations', () => { }); test('geo-containment alert migration should preserve foreign references', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = { ...getMockData({ alertTypeId: '.geo-containment', @@ -1984,7 +2042,9 @@ describe('successful migrations', () => { }); test('geo-containment alert migration ignores other alert-types', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const alert = { ...getMockData({ alertTypeId: '.foo', @@ -2008,13 +2068,13 @@ describe('successful migrations', () => { describe('8.0.0', () => { test('no op migration for rules SO', () => { - const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; + const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.0.0']; const alert = getMockData({}, true); expect(migration800(alert, migrationContext)).toEqual(alert); }); test('add threatIndicatorPath default value to threat match rules if missing', () => { - const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; + const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.0.0']; const alert = getMockData( { params: { type: 'threat_match' }, alertTypeId: 'siem.signals' }, true @@ -2025,7 +2085,7 @@ describe('successful migrations', () => { }); test('doesnt change threatIndicatorPath value in threat match rules if value is present', () => { - const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; + const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.0.0']; const alert = getMockData( { params: { type: 'threat_match', threatIndicatorPath: 'custom.indicator.path' }, @@ -2039,7 +2099,7 @@ describe('successful migrations', () => { }); test('doesnt change threatIndicatorPath value in other rules', () => { - const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; + const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.0.0']; const alert = getMockData({ params: { type: 'eql' }, alertTypeId: 'siem.signals' }, true); expect(migration800(alert, migrationContext).attributes.params.threatIndicatorPath).toEqual( undefined @@ -2047,7 +2107,7 @@ describe('successful migrations', () => { }); test('doesnt change threatIndicatorPath value if not a siem.signals rule', () => { - const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; + const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.0.0']; const alert = getMockData( { params: { type: 'threat_match' }, alertTypeId: 'not.siem.signals' }, true @@ -2058,7 +2118,7 @@ describe('successful migrations', () => { }); test('doesnt change AAD rule params if not a siem.signals rule', () => { - const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; + const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['8.0.0']; const alert = getMockData( { params: { outputIndex: 'output-index', type: 'query' }, alertTypeId: 'not.siem.signals' }, true @@ -2073,7 +2133,9 @@ describe('successful migrations', () => { test.each(Object.keys(ruleTypeMappings) as RuleType[])( 'changes AAD rule params accordingly if rule is a siem.signals %p rule', (ruleType) => { - const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; + const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '8.0.0' + ]; const alert = getMockData( { params: { outputIndex: 'output-index', type: ruleType }, alertTypeId: 'siem.signals' }, true @@ -2118,7 +2180,7 @@ describe('successful migrations', () => { ); test('Does not update rule tags if rule has already been enabled', () => { - const migrations = getMigrations(encryptedSavedObjectsSetup, isPreconfigured); + const migrations = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured); const migration800 = migrations['8.0.0']; const migration801 = migrations['8.0.1']; @@ -2141,7 +2203,7 @@ describe('successful migrations', () => { }); test('Does not update rule tags if rule was already disabled before upgrading to 8.0', () => { - const migrations = getMigrations(encryptedSavedObjectsSetup, isPreconfigured); + const migrations = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured); const migration800 = migrations['8.0.0']; const migration801 = migrations['8.0.1']; @@ -2161,7 +2223,7 @@ describe('successful migrations', () => { }); test('Updates rule tags if rule was auto-disabled in 8.0 upgrade and not reenabled', () => { - const migrations = getMigrations(encryptedSavedObjectsSetup, isPreconfigured); + const migrations = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured); const migration800 = migrations['8.0.0']; const migration801 = migrations['8.0.1']; @@ -2181,7 +2243,7 @@ describe('successful migrations', () => { }); test('Updates rule tags correctly if tags are undefined', () => { - const migrations = getMigrations(encryptedSavedObjectsSetup, isPreconfigured); + const migrations = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured); const migration801 = migrations['8.0.1']; const alert = { @@ -2204,7 +2266,7 @@ describe('successful migrations', () => { }); test('Updates rule tags correctly if tags are null', () => { - const migrations = getMigrations(encryptedSavedObjectsSetup, isPreconfigured); + const migrations = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured); const migration801 = migrations['8.0.1']; const alert = { @@ -2231,7 +2293,9 @@ describe('successful migrations', () => { describe('8.2.0', () => { test('migrates params to mapped_params', () => { - const migration820 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.2.0']; + const migration820 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '8.2.0' + ]; const alert = getMockData( { params: { @@ -2254,8 +2318,29 @@ describe('successful migrations', () => { }); describe('8.3.0', () => { + test('migrates es_query alert params', () => { + const migration830 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '8.3.0' + ]; + const alert = getMockData( + { + params: { esQuery: '{ "query": "test-query" }' }, + alertTypeId: '.es-query', + }, + true + ); + const migratedAlert820 = migration830(alert, migrationContext); + + expect(migratedAlert820.attributes.params).toEqual({ + esQuery: '{ "query": "test-query" }', + searchType: 'esQuery', + }); + }); + test('removes internal tags', () => { - const migration830 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.3.0']; + const migration830 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '8.3.0' + ]; const alert = getMockData( { tags: [ @@ -2274,7 +2359,9 @@ describe('successful migrations', () => { }); test('do not remove internal tags if rule is not Security solution rule', () => { - const migration830 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.3.0']; + const migration830 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '8.3.0' + ]; const alert = getMockData( { tags: ['__internal_immutable:false', 'tag-1'], @@ -2290,7 +2377,9 @@ describe('successful migrations', () => { describe('Metrics Inventory Threshold rule', () => { test('Migrates incorrect action group spelling', () => { - const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; + const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '8.0.0' + ]; const actions = [ { @@ -2317,7 +2406,9 @@ describe('successful migrations', () => { }); test('Works with the correct action group spelling', () => { - const migration800 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['8.0.0']; + const migration800 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '8.0.0' + ]; const actions = [ { @@ -2346,6 +2437,72 @@ describe('successful migrations', () => { }); }); +describe('search source migration', () => { + it('should apply migration within es query alert rule', () => { + const esQueryRuleSavedObject = { + attributes: { + params: { + searchConfiguration: { + some: 'prop', + migrated: false, + }, + }, + }, + } as SavedObjectUnsanitizedDoc; + + const versionToTest = '9.1.3'; + const migrations = getMigrations( + encryptedSavedObjectsSetup, + { + [versionToTest]: (state) => ({ ...state, migrated: true }), + }, + isPreconfigured + ); + + expect( + migrations[versionToTest](esQueryRuleSavedObject, {} as SavedObjectMigrationContext) + ).toEqual({ + attributes: { + params: { + searchConfiguration: { + some: 'prop', + migrated: true, + }, + }, + }, + }); + }); + + it('should not apply migration within es query alert rule when searchConfiguration not an object', () => { + const esQueryRuleSavedObject = { + attributes: { + params: { + searchConfiguration: 5, + }, + }, + } as SavedObjectUnsanitizedDoc; + + const versionToTest = '9.1.4'; + const migrations = getMigrations( + encryptedSavedObjectsSetup, + { + [versionToTest]: (state) => ({ ...state, migrated: true }), + }, + isPreconfigured + ); + + expect( + migrations[versionToTest](esQueryRuleSavedObject, {} as SavedObjectMigrationContext) + ).toEqual({ + attributes: { + params: { + searchConfiguration: 5, + }, + }, + }); + }); +}); + describe('handles errors during migrations', () => { beforeEach(() => { jest.resetAllMocks(); @@ -2355,7 +2512,7 @@ describe('handles errors during migrations', () => { }); describe('7.10.0 throws if migration fails', () => { test('should show the proper exception', () => { - const migration710 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.10.0']; + const migration710 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.10.0']; const alert = getMockData({ consumer: 'alerting', }); @@ -2380,7 +2537,7 @@ describe('handles errors during migrations', () => { describe('7.11.0 throws if migration fails', () => { test('should show the proper exception', () => { - const migration711 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.0']; + const migration711 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)['7.11.0']; const alert = getMockData({ consumer: 'alerting', }); @@ -2405,7 +2562,9 @@ describe('handles errors during migrations', () => { describe('7.11.2 throws if migration fails', () => { test('should show the proper exception', () => { - const migration7112 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.11.2']; + const migration7112 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.11.2' + ]; const alert = getMockData({ consumer: 'alerting', }); @@ -2430,7 +2589,9 @@ describe('handles errors during migrations', () => { describe('7.13.0 throws if migration fails', () => { test('should show the proper exception', () => { - const migration7130 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.13.0']; + const migration7130 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.13.0' + ]; const alert = getMockData({ consumer: 'alerting', }); @@ -2455,7 +2616,9 @@ describe('handles errors during migrations', () => { describe('7.16.0 throws if migration fails', () => { test('should show the proper exception', () => { - const migration7160 = getMigrations(encryptedSavedObjectsSetup, isPreconfigured)['7.16.0']; + const migration7160 = getMigrations(encryptedSavedObjectsSetup, {}, isPreconfigured)[ + '7.16.0' + ]; const rule = getMockData(); expect(() => { migration7160(rule, migrationContext); @@ -2475,6 +2638,53 @@ describe('handles errors during migrations', () => { ); }); }); + + describe('8.3.0 throws if migration fails', () => { + test('should show the proper exception on search source migration', () => { + encryptedSavedObjectsSetup.createMigration.mockImplementation(({ migration }) => migration); + const mockRule = getMockData(); + const rule = { + ...mockRule, + attributes: { + ...mockRule.attributes, + params: { + searchConfiguration: { + some: 'prop', + migrated: false, + }, + }, + }, + }; + + const versionToTest = '8.3.0'; + const migration830 = getMigrations( + encryptedSavedObjectsSetup, + { + [versionToTest]: () => { + throw new Error(`Can't migrate search source!`); + }, + }, + isPreconfigured + )[versionToTest]; + + expect(() => { + migration830(rule, migrationContext); + }).toThrowError(`Can't migrate search source!`); + expect(migrationContext.log.error).toHaveBeenCalledWith( + `encryptedSavedObject ${versionToTest} migration failed for alert ${rule.id} with error: Can't migrate search source!`, + { + migrations: { + alertDocument: { + ...rule, + attributes: { + ...rule.attributes, + }, + }, + }, + } + ); + }); + }); }); function getUpdatedAt(): string { diff --git a/x-pack/plugins/alerting/server/saved_objects/migrations.ts b/x-pack/plugins/alerting/server/saved_objects/migrations.ts index 69d88e196dcfdaf..b3f8d873d8ef03d 100644 --- a/x-pack/plugins/alerting/server/saved_objects/migrations.ts +++ b/x-pack/plugins/alerting/server/saved_objects/migrations.ts @@ -7,6 +7,7 @@ import { isRuleType, ruleTypeMappings } from '@kbn/securitysolution-rules'; import { isString } from 'lodash/fp'; +import { gte } from 'semver'; import { LogMeta, SavedObjectMigrationMap, @@ -19,12 +20,16 @@ import { } from '@kbn/core/server'; import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; import type { IsMigrationNeededPredicate } from '@kbn/encrypted-saved-objects-plugin/server'; -import { RawRule, RawRuleAction, RawRuleExecutionStatus } from '../types'; +import { MigrateFunctionsObject, MigrateFunction } from '@kbn/kibana-utils-plugin/common'; +import { mergeSavedObjectMigrationMaps } from '@kbn/core/server'; +import { isSerializedSearchSource, SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import { extractRefsFromGeoContainmentAlert } from './geo_containment/migrations'; +import { RawRule, RawRuleAction, RawRuleExecutionStatus } from '../types'; import { getMappedParams } from '../rules_client/lib/mapped_params_utils'; const SIEM_APP_ID = 'securitySolution'; const SIEM_SERVER_APP_ID = 'siem'; +const MINIMUM_SS_MIGRATION_VERSION = '8.3.0'; export const LEGACY_LAST_MODIFIED_VERSION = 'pre-7.10.0'; export const FILEBEAT_7X_INDICATOR_PATH = 'threatintel.indicator'; @@ -59,6 +64,9 @@ export const isAnyActionSupportIncidents = (doc: SavedObjectUnsanitizedDoc): boolean => doc.attributes.alertTypeId === 'siem.signals'; +export const isEsQueryRuleType = (doc: SavedObjectUnsanitizedDoc) => + doc.attributes.alertTypeId === '.es-query'; + export const isDetectionEngineAADRuleType = (doc: SavedObjectUnsanitizedDoc): boolean => (Object.values(ruleTypeMappings) as string[]).includes(doc.attributes.alertTypeId); @@ -75,6 +83,7 @@ export const isSecuritySolutionLegacyNotification = ( export function getMigrations( encryptedSavedObjects: EncryptedSavedObjectsPluginSetup, + searchSourceMigrations: MigrateFunctionsObject, isPreconfigured: (connectorId: string) => boolean ): SavedObjectMigrationMap { const migrationWhenRBACWasIntroduced = createEsoMigration( @@ -155,22 +164,25 @@ export function getMigrations( const migrationRules830 = createEsoMigration( encryptedSavedObjects, (doc: SavedObjectUnsanitizedDoc): doc is SavedObjectUnsanitizedDoc => true, - pipeMigrations(removeInternalTags) + pipeMigrations(addSearchType, removeInternalTags) ); - return { - '7.10.0': executeMigrationWithErrorHandling(migrationWhenRBACWasIntroduced, '7.10.0'), - '7.11.0': executeMigrationWithErrorHandling(migrationAlertUpdatedAtAndNotifyWhen, '7.11.0'), - '7.11.2': executeMigrationWithErrorHandling(migrationActions7112, '7.11.2'), - '7.13.0': executeMigrationWithErrorHandling(migrationSecurityRules713, '7.13.0'), - '7.14.1': executeMigrationWithErrorHandling(migrationSecurityRules714, '7.14.1'), - '7.15.0': executeMigrationWithErrorHandling(migrationSecurityRules715, '7.15.0'), - '7.16.0': executeMigrationWithErrorHandling(migrateRules716, '7.16.0'), - '8.0.0': executeMigrationWithErrorHandling(migrationRules800, '8.0.0'), - '8.0.1': executeMigrationWithErrorHandling(migrationRules801, '8.0.1'), - '8.2.0': executeMigrationWithErrorHandling(migrationRules820, '8.2.0'), - '8.3.0': executeMigrationWithErrorHandling(migrationRules830, '8.3.0'), - }; + return mergeSavedObjectMigrationMaps( + { + '7.10.0': executeMigrationWithErrorHandling(migrationWhenRBACWasIntroduced, '7.10.0'), + '7.11.0': executeMigrationWithErrorHandling(migrationAlertUpdatedAtAndNotifyWhen, '7.11.0'), + '7.11.2': executeMigrationWithErrorHandling(migrationActions7112, '7.11.2'), + '7.13.0': executeMigrationWithErrorHandling(migrationSecurityRules713, '7.13.0'), + '7.14.1': executeMigrationWithErrorHandling(migrationSecurityRules714, '7.14.1'), + '7.15.0': executeMigrationWithErrorHandling(migrationSecurityRules715, '7.15.0'), + '7.16.0': executeMigrationWithErrorHandling(migrateRules716, '7.16.0'), + '8.0.0': executeMigrationWithErrorHandling(migrationRules800, '8.0.0'), + '8.0.1': executeMigrationWithErrorHandling(migrationRules801, '8.0.1'), + '8.2.0': executeMigrationWithErrorHandling(migrationRules820, '8.2.0'), + '8.3.0': executeMigrationWithErrorHandling(migrationRules830, '8.3.0'), + }, + getSearchSourceMigrations(encryptedSavedObjects, searchSourceMigrations) + ); } function executeMigrationWithErrorHandling( @@ -697,6 +709,23 @@ function addSecuritySolutionAADRuleTypes( : doc; } +function addSearchType(doc: SavedObjectUnsanitizedDoc) { + const searchType = doc.attributes.params.searchType; + + return isEsQueryRuleType(doc) && !searchType + ? { + ...doc, + attributes: { + ...doc.attributes, + params: { + ...doc.attributes.params, + searchType: 'esQuery', + }, + }, + } + : doc; +} + function addSecuritySolutionAADRuleTypeTags( doc: SavedObjectUnsanitizedDoc ): SavedObjectUnsanitizedDoc { @@ -902,3 +931,56 @@ function pipeMigrations(...migrations: AlertMigration[]): AlertMigration { return (doc: SavedObjectUnsanitizedDoc) => migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc); } + +function mapSearchSourceMigrationFunc( + migrateSerializedSearchSourceFields: MigrateFunction +): MigrateFunction { + return (doc) => { + const _doc = doc as { attributes: RawRule }; + + const serializedSearchSource = _doc.attributes.params.searchConfiguration; + + if (isSerializedSearchSource(serializedSearchSource)) { + return { + ..._doc, + attributes: { + ..._doc.attributes, + params: { + ..._doc.attributes.params, + searchConfiguration: migrateSerializedSearchSourceFields(serializedSearchSource), + }, + }, + }; + } + return _doc; + }; +} + +/** + * This creates a migration map that applies search source migrations to legacy es query rules. + * It doesn't modify existing migrations. The following migrations will occur at minimum version of 8.3+. + */ +function getSearchSourceMigrations( + encryptedSavedObjects: EncryptedSavedObjectsPluginSetup, + searchSourceMigrations: MigrateFunctionsObject +) { + const filteredMigrations: SavedObjectMigrationMap = {}; + for (const versionKey in searchSourceMigrations) { + if (gte(versionKey, MINIMUM_SS_MIGRATION_VERSION)) { + const migrateSearchSource = mapSearchSourceMigrationFunc( + searchSourceMigrations[versionKey] + ) as unknown as AlertMigration; + + filteredMigrations[versionKey] = executeMigrationWithErrorHandling( + createEsoMigration( + encryptedSavedObjects, + (doc: SavedObjectUnsanitizedDoc): doc is SavedObjectUnsanitizedDoc => + isEsQueryRuleType(doc), + pipeMigrations(migrateSearchSource) + ), + versionKey + ); + } + } + return filteredMigrations; +} diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts index 81f7fa7da02d229..b4ae05fd341b030 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts @@ -13,7 +13,6 @@ import { actionsMock, renderActionParameterTemplatesDefault, } from '@kbn/actions-plugin/server/mocks'; -import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock'; import { KibanaRequest } from '@kbn/core/server'; import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; import { InjectActionParamsOpts } from './inject_action_params'; @@ -26,11 +25,14 @@ import { RuleTypeState, } from '../types'; import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; +import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; jest.mock('./inject_action_params', () => ({ injectActionParams: jest.fn(), })); +const alertingEventLogger = alertingEventLoggerMock.create(); + const ruleType: NormalizedRuleType< RuleTypeParams, RuleTypeParams, @@ -60,7 +62,6 @@ const ruleType: NormalizedRuleType< const actionsClient = actionsClientMock.create(); const mockActionsPlugin = actionsMock.createStart(); -const mockEventLogger = eventLoggerMock.create(); const createExecutionHandlerParams: jest.Mocked< CreateExecutionHandlerOptions< RuleTypeParams, @@ -83,7 +84,7 @@ const createExecutionHandlerParams: jest.Mocked< kibanaBaseUrl: 'http://localhost:5601', ruleType, logger: loggingSystemMock.create().get(), - eventLogger: mockEventLogger, + alertingEventLogger, actions: [ { id: '1', @@ -178,63 +179,13 @@ describe('Create Execution Handler', () => { ] `); - expect(mockEventLogger.logEvent).toHaveBeenCalledTimes(1); - expect(mockEventLogger.logEvent.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "event": Object { - "action": "execute-action", - "category": Array [ - "alerts", - ], - "kind": "alert", - }, - "kibana": Object { - "alert": Object { - "rule": Object { - "consumer": "rule-consumer", - "execution": Object { - "uuid": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", - }, - "rule_type_id": "test", - }, - }, - "alerting": Object { - "action_group_id": "default", - "instance_id": "2", - }, - "saved_objects": Array [ - Object { - "id": "1", - "namespace": "test1", - "rel": "primary", - "type": "alert", - "type_id": "test", - }, - Object { - "id": "1", - "namespace": "test1", - "type": "action", - "type_id": "test", - }, - ], - "space_ids": Array [ - "test1", - ], - }, - "message": "alert: test:1: 'name-of-alert' instanceId: '2' scheduled actionGroup: 'default' action: test:1", - "rule": Object { - "category": "test", - "id": "1", - "license": "basic", - "name": "name-of-alert", - "ruleset": "alerts", - }, - }, - ], - ] - `); + expect(alertingEventLogger.logAction).toHaveBeenCalledTimes(1); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, { + id: '1', + typeId: 'test', + alertId: '2', + alertGroup: 'default', + }); expect(jest.requireMock('./inject_action_params').injectActionParams).toHaveBeenCalledWith({ ruleId: '1', diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts index ce212a3cbff1b32..0383289ab91dfb8 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts @@ -5,10 +5,8 @@ * 2.0. */ import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; -import { SAVED_OBJECT_REL_PRIMARY } from '@kbn/event-log-plugin/server'; import { isEphemeralTaskRejectedDueToCapacityError } from '@kbn/task-manager-plugin/server'; import { transformActionParams } from './transform_action_params'; -import { EVENT_LOG_ACTIONS } from '../plugin'; import { injectActionParams } from './inject_action_params'; import { ActionsCompletion, @@ -17,9 +15,6 @@ import { RuleTypeParams, RuleTypeState, } from '../types'; - -import { UntypedNormalizedRuleType } from '../rule_type_registry'; -import { createAlertEventLogRecordObject } from '../lib/create_alert_event_log_record_object'; import { CreateExecutionHandlerOptions, ExecutionHandlerOptions } from './types'; export type ExecutionHandler = ( @@ -47,7 +42,7 @@ export function createExecutionHandler< apiKey, ruleType, kibanaBaseUrl, - eventLogger, + alertingEventLogger, request, ruleParams, supportsEphemeralTasks, @@ -117,8 +112,6 @@ export function createExecutionHandler< ruleRunMetricsStore.incrementNumberOfGeneratedActions(actions.length); - const ruleLabel = `${ruleType.id}:${ruleId}: '${ruleName}'`; - const actionsClient = await actionsPlugin.getActionsClientWithRequest(request); let ephemeralActionsToSchedule = maxEphemeralActionsPerRule; @@ -189,8 +182,6 @@ export function createExecutionHandler< ], }; - // TODO would be nice to add the action name here, but it's not available - const actionLabel = `${actionTypeId}:${action.id}`; if (supportsEphemeralTasks && ephemeralActionsToSchedule > 0) { ephemeralActionsToSchedule--; try { @@ -204,39 +195,13 @@ export function createExecutionHandler< await actionsClient.enqueueExecution(enqueueOptions); } - const event = createAlertEventLogRecordObject({ - ruleId, - ruleType: ruleType as UntypedNormalizedRuleType, - consumer: ruleConsumer, - action: EVENT_LOG_ACTIONS.executeAction, - executionId, - spaceId, - instanceId: alertId, - group: actionGroup, - subgroup: actionSubgroup, - ruleName, - savedObjects: [ - { - type: 'alert', - id: ruleId, - typeId: ruleType.id, - relation: SAVED_OBJECT_REL_PRIMARY, - }, - { - type: 'action', - id: action.id, - typeId: actionTypeId, - }, - ], - ...namespace, - message: `alert: ${ruleLabel} instanceId: '${alertId}' scheduled ${ - actionSubgroup - ? `actionGroup(subgroup): '${actionGroup}(${actionSubgroup})'` - : `actionGroup: '${actionGroup}'` - } action: ${actionLabel}`, + alertingEventLogger.logAction({ + id: action.id, + typeId: actionTypeId, + alertId, + alertGroup: actionGroup, + alertSubgroup: actionSubgroup, }); - - eventLogger.logEvent(event); } }; } diff --git a/x-pack/plugins/alerting/server/task_runner/fixtures.ts b/x-pack/plugins/alerting/server/task_runner/fixtures.ts index 861f1a4bbec9197..5e4594cda6c04cc 100644 --- a/x-pack/plugins/alerting/server/task_runner/fixtures.ts +++ b/x-pack/plugins/alerting/server/task_runner/fixtures.ts @@ -5,14 +5,8 @@ * 2.0. */ -import { isNil } from 'lodash'; import { TaskStatus } from '@kbn/task-manager-plugin/server'; -import { - Rule, - RuleExecutionStatusWarningReasons, - RuleTypeParams, - RecoveredActionGroup, -} from '../../common'; +import { Rule, RuleTypeParams, RecoveredActionGroup } from '../../common'; import { getDefaultRuleMonitoring } from './task_runner'; import { UntypedNormalizedRuleType } from '../rule_type_registry'; import { EVENT_LOG_ACTIONS } from '../plugin'; @@ -108,6 +102,8 @@ export const ruleType: jest.Mocked = { recoveryActionGroup: RecoveredActionGroup, executor: jest.fn(), producer: 'alerts', + cancelAlertsOnRuleTimeout: true, + ruleTaskTimeout: '5m', }; export const mockRunNowResponse = { @@ -182,178 +178,45 @@ export const mockTaskInstance = () => ({ ownerId: null, }); -export const generateAlertSO = (id: string) => ({ - id, - rel: 'primary', - type: 'alert', - type_id: RULE_TYPE_ID, -}); +export const generateAlertOpts = ({ action, group, subgroup, state, id }: GeneratorParams = {}) => { + id = id ?? '1'; + let message: string = ''; + switch (action) { + case EVENT_LOG_ACTIONS.newInstance: + message = `test:1: 'rule-name' created new alert: '${id}'`; + break; + case EVENT_LOG_ACTIONS.activeInstance: + message = subgroup + ? `test:1: 'rule-name' active alert: '${id}' in actionGroup(subgroup): 'default(${subgroup})'` + : `test:1: 'rule-name' active alert: '${id}' in actionGroup: 'default'`; + break; + case EVENT_LOG_ACTIONS.recoveredInstance: + message = `test:1: 'rule-name' alert '${id}' has recovered`; + break; + } + return { + action, + id, + message, + state, + ...(group ? { group } : {}), + ...(subgroup ? { subgroup } : {}), + }; +}; -export const generateActionSO = (id: string) => ({ +export const generateActionOpts = ({ + subgroup, id, - namespace: undefined, - type: 'action', - type_id: 'action', -}); - -export const generateEventLog = ({ - action, - task, - duration, - consumer, - start, - end, - outcome, - reason, - instanceId, - actionSubgroup, - actionGroupId, - actionId, - status, - numberOfTriggeredActions, - numberOfGeneratedActions, - numberOfActiveAlerts, - numberOfRecoveredAlerts, - numberOfNewAlerts, - savedObjects = [generateAlertSO('1')], + alertGroup, + alertId, }: GeneratorParams = {}) => ({ - ...(status === 'error' && { - error: { - message: generateErrorMessage(String(reason)), - }, - }), - event: { - action, - ...(!isNil(duration) && { duration }), - ...(start && { start }), - ...(end && { end }), - ...(outcome && { outcome }), - ...(reason && { reason }), - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - ...(consumer && { consumer }), - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - ...((!isNil(numberOfTriggeredActions) || !isNil(numberOfGeneratedActions)) && { - metrics: { - number_of_triggered_actions: numberOfTriggeredActions, - number_of_generated_actions: numberOfGeneratedActions, - number_of_active_alerts: numberOfActiveAlerts ?? 0, - number_of_new_alerts: numberOfNewAlerts ?? 0, - number_of_recovered_alerts: numberOfRecoveredAlerts ?? 0, - total_number_of_alerts: - ((numberOfActiveAlerts ?? 0) as number) + - ((numberOfRecoveredAlerts ?? 0) as number), - number_of_searches: 3, - es_search_duration_ms: 33, - total_search_duration_ms: 23423, - }, - }), - }, - rule_type_id: 'test', - }, - }, - ...((actionSubgroup || actionGroupId || instanceId || status) && { - alerting: { - ...(actionSubgroup && { action_subgroup: actionSubgroup }), - ...(actionGroupId && { action_group_id: actionGroupId }), - ...(instanceId && { instance_id: instanceId }), - ...(status && { status }), - }, - }), - saved_objects: savedObjects, - space_ids: ['default'], - ...(task && { - task: { - schedule_delay: 0, - scheduled: DATE_1970, - }, - }), - }, - message: generateMessage({ - action, - instanceId, - actionGroupId, - actionSubgroup, - reason, - status, - actionId, - }), - rule: { - category: 'test', - id: '1', - license: 'basic', - ...(hasRuleName({ action, status }) && { name: RULE_NAME }), - ruleset: 'alerts', - }, + id: id ?? '1', + typeId: 'action', + alertId: alertId ?? '1', + alertGroup: alertGroup ?? 'default', + ...(subgroup ? { alertSubgroup: subgroup } : {}), }); -const generateMessage = ({ - action, - instanceId, - actionGroupId, - actionSubgroup, - actionId, - reason, - status, -}: GeneratorParams) => { - if (action === EVENT_LOG_ACTIONS.executeStart) { - return `rule execution start: "${mockTaskInstance().params.alertId}"`; - } - - if (action === EVENT_LOG_ACTIONS.newInstance) { - return `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' created new alert: '${instanceId}'`; - } - - if (action === EVENT_LOG_ACTIONS.activeInstance) { - if (actionSubgroup) { - return `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' active alert: '${instanceId}' in actionGroup(subgroup): 'default(${actionSubgroup})'`; - } - return `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' active alert: '${instanceId}' in actionGroup: '${actionGroupId}'`; - } - - if (action === EVENT_LOG_ACTIONS.recoveredInstance) { - return `${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' alert '${instanceId}' has recovered`; - } - - if (action === EVENT_LOG_ACTIONS.executeAction) { - if (actionSubgroup) { - return `alert: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' instanceId: '${instanceId}' scheduled actionGroup(subgroup): 'default(${actionSubgroup})' action: action:${actionId}`; - } - return `alert: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}' instanceId: '${instanceId}' scheduled actionGroup: '${actionGroupId}' action: action:${actionId}`; - } - - if (action === EVENT_LOG_ACTIONS.execute) { - if (status === 'error' && reason === 'execute') { - return `rule execution failure: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`; - } - if (status === 'error') { - return `${RULE_TYPE_ID}:${RULE_ID}: execution failed`; - } - if (actionGroupId === 'recovered') { - return `rule-name' instanceId: '${instanceId}' scheduled actionGroup: '${actionGroupId}' action: action:${actionId}`; - } - if ( - status === 'warning' && - reason === RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS - ) { - return `The maximum number of actions for this rule type was reached; excess actions were not triggered.`; - } - return `rule executed: ${RULE_TYPE_ID}:${RULE_ID}: '${RULE_NAME}'`; - } -}; - -const generateErrorMessage = (reason: string) => { - if (reason === 'disabled') { - return 'Rule failed to execute because rule ran after it was disabled.'; - } - return GENERIC_ERROR_MESSAGE; -}; - export const generateRunnerResult = ({ successRatio = 1, history = Array(false), @@ -424,6 +287,3 @@ export const generateAlertInstance = ({ id, duration, start }: GeneratorParams = }, }, }); -const hasRuleName = ({ action, status }: GeneratorParams) => { - return action !== 'execute-start' && status !== 'error'; -}; 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 5318988e697c682..7d95f63f3c43c20 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 @@ -45,9 +45,8 @@ import { ExecuteOptions } from '@kbn/actions-plugin/server/create_execute_functi import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock'; import moment from 'moment'; import { - generateActionSO, - generateAlertSO, - generateEventLog, + generateAlertOpts, + generateActionOpts, mockDate, mockedRuleTypeSavedObject, mockRunNowResponse, @@ -71,6 +70,11 @@ import { EVENT_LOG_ACTIONS } from '../plugin'; import { IN_MEMORY_METRICS } from '../monitoring'; import { translations } from '../constants/translations'; import { dataPluginMock } from '@kbn/data-plugin/server/mocks'; +import { + AlertingEventLogger, + RuleContextOpts, +} from '../lib/alerting_event_logger/alerting_event_logger'; +import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; jest.mock('uuid', () => ({ v4: () => '5f6aa57d-3e22-484e-bae8-cbed868f4d28', @@ -80,17 +84,30 @@ jest.mock('../lib/wrap_scoped_cluster_client', () => ({ createWrappedScopedClusterClientFactory: jest.fn(), })); +jest.mock('../lib/alerting_event_logger/alerting_event_logger'); + let fakeTimer: sinon.SinonFakeTimers; const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); +const alertingEventLogger = alertingEventLoggerMock.create(); describe('Task Runner', () => { let mockedTaskInstance: ConcreteTaskInstance; + let alertingEventLoggerInitializer: RuleContextOpts; beforeAll(() => { fakeTimer = sinon.useFakeTimers(); mockedTaskInstance = mockTaskInstance(); + + alertingEventLoggerInitializer = { + consumer: mockedTaskInstance.params.consumer, + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + ruleId: mockedTaskInstance.params.alertId, + ruleType, + spaceId: mockedTaskInstance.params.spaceId, + taskScheduledAt: mockedTaskInstance.scheduledAt, + }; }); afterAll(() => fakeTimer.restore()); @@ -186,6 +203,9 @@ describe('Task Runner', () => { ); mockedRuleTypeSavedObject.monitoring!.execution.history = []; mockedRuleTypeSavedObject.monitoring!.execution.calculated_metrics.success_ratio = 0; + + alertingEventLogger.getStartAndDuration.mockImplementation(() => ({ start: new Date() })); + (AlertingEventLogger as jest.Mock).mockImplementation(() => alertingEventLogger); }); test('successfully executes the task', async () => { @@ -201,10 +221,13 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); const runnerResult = await taskRunner.run(); expect(runnerResult).toEqual(generateRunnerResult({ state: true, history: [true] })); + expect(ruleType.executor).toHaveBeenCalledTimes(1); const call = ruleType.executor.mock.calls[0][0]; expect(call.params).toEqual({ bar: true }); @@ -247,16 +270,7 @@ describe('Task Runner', () => { 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"triggeredActionsStatus":"complete"}' ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenCalledWith( - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); + testAlertingEventLogCalls({ status: 'ok' }); expect( taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update @@ -309,6 +323,8 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); @@ -331,66 +347,38 @@ describe('Task Runner', () => { 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"triggeredActionsStatus":"complete"}' ); - const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(5); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 1, + generatedActions: 1, + newAlerts: 1, + triggeredActions: 1, + status: 'active', + logAlert: 2, + logAction: 1, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - duration: '0', - start: DATE_1970, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.newInstance, - actionSubgroup: 'subDefault', - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', + group: 'default', + subgroup: 'subDefault', + state: { start: DATE_1970, duration: '0' }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ - duration: '0', - start: DATE_1970, + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - actionSubgroup: 'subDefault', - instanceId: '1', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.executeAction, - actionGroupId: 'default', - instanceId: '1', - actionSubgroup: 'subDefault', - savedObjects: [generateAlertSO('1'), generateActionSO('1')], - consumer: 'bar', - actionId: '1', + group: 'default', + subgroup: 'subDefault', + state: { start: DATE_1970, duration: '0' }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 5, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 1, - numberOfGeneratedActions: 1, - numberOfActiveAlerts: 1, - numberOfNewAlerts: 1, - task: true, - consumer: 'bar', - }) + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith( + 1, + generateActionOpts({ subgroup: 'subDefault' }) ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); } ); @@ -417,6 +405,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, muteAll: true, @@ -445,53 +435,29 @@ describe('Task Runner', () => { 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"triggeredActionsStatus":"complete"}' ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 1, + newAlerts: 1, + status: 'active', + logAlert: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - duration: '0', - start: DATE_1970, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.newInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ - duration: '0', - start: DATE_1970, + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - numberOfActiveAlerts: 1, - numberOfNewAlerts: 1, - task: true, - consumer: 'bar', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -536,6 +502,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, muteAll, @@ -588,6 +556,8 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, mutedInstanceIds: ['2'], @@ -666,6 +636,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, throttle: '1d', @@ -709,6 +681,8 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, mutedInstanceIds: ['2'], @@ -765,6 +739,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', @@ -773,46 +749,25 @@ describe('Task Runner', () => { await taskRunner.run(); expect(actionsClient.ephemeralEnqueuedExecution).toHaveBeenCalledTimes(0); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(3); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 1, + status: 'active', + logAlert: 1, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - duration: MOCK_DURATION, - start: DATE_1969, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - numberOfActiveAlerts: 1, - task: true, - consumer: 'bar', + group: 'default', + state: { start: DATE_1969, duration: MOCK_DURATION, bar: false }, }) ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test.each(ephemeralTestParams)( - 'actionsPlugin.execute is called when notifyWhen=onActionGroupChange and alert alert state has changed %s', + 'actionsPlugin.execute is called when notifyWhen=onActionGroupChange and alert state has changed %s', async (nameExtension, customTaskRunnerFactoryInitializerParams, enqueueFunction) => { customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue( true @@ -852,28 +807,34 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', }); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); - const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; await taskRunner.run(); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 1, - numberOfGeneratedActions: 1, - numberOfActiveAlerts: 1, - task: true, - consumer: 'bar', + testAlertingEventLogCalls({ + activeAlerts: 1, + triggeredActions: 1, + generatedActions: 1, + status: 'active', + logAlert: 1, + logAction: 1, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 1, + generateAlertOpts({ + action: EVENT_LOG_ACTIONS.activeInstance, + group: 'default', + state: { bar: false }, }) ); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, generateActionOpts({})); + expect(enqueueFunction).toHaveBeenCalledTimes(1); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); } @@ -927,6 +888,8 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', @@ -934,21 +897,27 @@ describe('Task Runner', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); - const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 1, - numberOfGeneratedActions: 1, - numberOfActiveAlerts: 1, - task: true, - consumer: 'bar', + testAlertingEventLogCalls({ + activeAlerts: 1, + triggeredActions: 1, + generatedActions: 1, + status: 'active', + logAlert: 1, + logAction: 1, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 1, + generateAlertOpts({ + action: EVENT_LOG_ACTIONS.activeInstance, + state: { bar: false }, + group: 'default', + subgroup: 'subgroup1', }) ); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith( + 1, + generateActionOpts({ subgroup: 'subgroup1' }) + ); expect(enqueueFunction).toHaveBeenCalledTimes(1); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); @@ -984,6 +953,8 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); await taskRunner.run(); @@ -1010,65 +981,33 @@ describe('Task Runner', () => { expect(enqueueFunction).toHaveBeenCalledTimes(1); expect(enqueueFunction).toHaveBeenCalledWith(generateEnqueueFunctionInput()); - const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(5); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 1, + newAlerts: 1, + triggeredActions: 1, + generatedActions: 1, + status: 'active', + logAlert: 2, + logAction: 1, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - duration: '0', - start: DATE_1970, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.newInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ - duration: '0', - start: DATE_1970, + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.executeAction, - actionGroupId: 'default', - instanceId: '1', - actionId: '1', - savedObjects: [generateAlertSO('1'), generateActionSO('1')], - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 5, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 1, - numberOfGeneratedActions: 1, - numberOfActiveAlerts: 1, - numberOfNewAlerts: 1, - task: true, - consumer: 'bar', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, generateActionOpts({})); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); } ); @@ -1126,6 +1065,8 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); const runnerResult = await taskRunner.run(); @@ -1153,76 +1094,40 @@ describe('Task Runner', () => { 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"triggeredActionsStatus":"complete"}' ); - const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 1, + recoveredAlerts: 1, + triggeredActions: 2, + generatedActions: 2, + status: 'active', + logAlert: 2, + logAction: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ + generateAlertOpts({ action: EVENT_LOG_ACTIONS.recoveredInstance, - duration: '64800000000000', - instanceId: '2', - start: '1969-12-31T06:00:00.000Z', - end: DATE_1970, - consumer: 'bar', + id: '2', + state: { + bar: false, + start: '1969-12-31T06:00:00.000Z', + duration: '64800000000000', + end: DATE_1970, + }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - duration: MOCK_DURATION, - start: DATE_1969, - instanceId: '1', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.executeAction, - savedObjects: [generateAlertSO('1'), generateActionSO('1')], - actionGroupId: 'default', - instanceId: '1', - actionId: '1', - consumer: 'bar', - }) - ); - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 5, - generateEventLog({ - action: EVENT_LOG_ACTIONS.executeAction, - savedObjects: [generateAlertSO('1'), generateActionSO('2')], - actionGroupId: 'recovered', - instanceId: '2', - actionId: '2', - consumer: 'bar', + group: 'default', + state: { bar: false, start: DATE_1969, duration: MOCK_DURATION }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 6, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 2, - numberOfGeneratedActions: 2, - numberOfActiveAlerts: 1, - numberOfRecoveredAlerts: 1, - task: true, - consumer: 'bar', - }) + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, generateActionOpts({})); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith( + 2, + generateActionOpts({ id: '2', alertId: '2', alertGroup: 'recovered' }) ); expect(enqueueFunction).toHaveBeenCalledTimes(2); @@ -1234,7 +1139,6 @@ describe('Task Runner', () => { test.each(ephemeralTestParams)( "should skip alertInstances which weren't active on the previous execution %s", async (nameExtension, customTaskRunnerFactoryInitializerParams, enqueueFunction) => { - const alertId = 'e558aaad-fd81-46d2-96fc-3bd8fc3dc03f'; customTaskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue( true ); @@ -1270,13 +1174,12 @@ describe('Task Runner', () => { '2': { meta: {}, state: { bar: false } }, }, }, - params: { - alertId, - }, }, customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); const runnerResult = await taskRunner.run(); @@ -1284,24 +1187,32 @@ describe('Task Runner', () => { const logger = customTaskRunnerFactoryInitializerParams.logger; expect(logger.debug).toHaveBeenCalledWith( - `rule test:${alertId}: '${RULE_NAME}' has 1 active alerts: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` + `rule test:1: '${RULE_NAME}' has 1 active alerts: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` ); expect(logger.debug).nthCalledWith( 3, - `rule test:${alertId}: '${RULE_NAME}' has 1 recovered alerts: [\"2\"]` + `rule test:1: '${RULE_NAME}' has 1 recovered alerts: [\"2\"]` ); expect(logger.debug).nthCalledWith( 4, - `ruleRunStatus for test:${alertId}: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}` + `ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}` ); expect(logger.debug).nthCalledWith( 5, - `ruleRunMetrics for test:${alertId}: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"triggeredActionsStatus":"complete"}` + `ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":2,"numberOfGeneratedActions":2,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":1,"numberOfNewAlerts":0,"triggeredActionsStatus":"complete"}` ); - const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); + testAlertingEventLogCalls({ + activeAlerts: 1, + recoveredAlerts: 1, + triggeredActions: 2, + generatedActions: 2, + status: 'active', + logAlert: 2, + logAction: 2, + }); + expect(enqueueFunction).toHaveBeenCalledTimes(2); expect((enqueueFunction as jest.Mock).mock.calls[1][0].id).toEqual('2'); expect((enqueueFunction as jest.Mock).mock.calls[0][0].id).toEqual('1'); @@ -1359,6 +1270,8 @@ describe('Task Runner', () => { customTaskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, actions: [ @@ -1384,8 +1297,20 @@ describe('Task Runner', () => { const runnerResult = await taskRunner.run(); expect(runnerResult.state.alertInstances).toEqual(generateAlertInstance()); - const eventLogger = customTaskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); + testAlertingEventLogCalls({ + ruleContext: { + ...alertingEventLoggerInitializer, + ruleType: ruleTypeWithCustomRecovery, + }, + activeAlerts: 1, + recoveredAlerts: 1, + triggeredActions: 2, + generatedActions: 2, + status: 'active', + logAlert: 2, + logAction: 2, + }); + expect(enqueueFunction).toHaveBeenCalledTimes(2); expect(enqueueFunction).toHaveBeenCalledWith(generateEnqueueFunctionInput()); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); @@ -1436,6 +1361,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); const runnerResult = await taskRunner.run(); @@ -1443,55 +1370,37 @@ describe('Task Runner', () => { generateAlertInstance({ id: 1, duration: MOCK_DURATION, start: DATE_1969 }) ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 1, + recoveredAlerts: 1, + triggeredActions: 0, + generatedActions: 2, + status: 'active', + logAlert: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ + generateAlertOpts({ action: EVENT_LOG_ACTIONS.recoveredInstance, - actionGroupId: 'default', - duration: '64800000000000', - instanceId: '2', - start: '1969-12-31T06:00:00.000Z', - end: DATE_1970, - consumer: 'bar', + id: '2', + group: 'default', + state: { + bar: false, + start: '1969-12-31T06:00:00.000Z', + duration: '64800000000000', + end: DATE_1970, + }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - duration: MOCK_DURATION, - start: DATE_1969, - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { bar: false, start: DATE_1969, duration: MOCK_DURATION }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 2, - numberOfActiveAlerts: 1, - numberOfRecoveredAlerts: 1, - task: true, - consumer: 'bar', - }) - ); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -1515,6 +1424,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); const runnerResult = await taskRunner.run(); @@ -1532,6 +1443,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); @@ -1560,6 +1473,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ ...SAVED_OBJECT, @@ -1590,6 +1505,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); rulesClient.get.mockResolvedValueOnce(mockedRuleTypeSavedObject); rulesClient.get.mockResolvedValueOnce({ @@ -1626,6 +1542,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); @@ -1633,28 +1550,13 @@ describe('Task Runner', () => { const runnerResult = await taskRunner.run(); expect(runnerResult).toEqual(generateRunnerResult({ successRatio: 0 })); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'failure', - reason: 'execute', - task: true, - status: 'error', - consumer: 'bar', - }) - ); + + testAlertingEventLogCalls({ + status: 'error', + errorReason: 'execute', + executionStatus: 'failed', + }); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -1669,6 +1571,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); @@ -1676,28 +1579,13 @@ describe('Task Runner', () => { expect(runnerResult).toEqual(generateRunnerResult({ successRatio: 0 })); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'failure', - task: true, - reason: 'decrypt', - status: 'error', - consumer: 'bar', - }) - ); + testAlertingEventLogCalls({ + setRuleName: false, + status: 'error', + errorReason: 'decrypt', + executionStatus: 'not-reached', + }); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -1712,6 +1600,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -1720,28 +1609,12 @@ describe('Task Runner', () => { expect(runnerResult).toEqual(generateRunnerResult({ successRatio: 0 })); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'failure', - task: true, - reason: 'license', - status: 'error', - consumer: 'bar', - }) - ); + testAlertingEventLogCalls({ + status: 'error', + errorReason: 'license', + executionStatus: 'not-reached', + }); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -1756,6 +1629,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -1764,20 +1638,13 @@ describe('Task Runner', () => { expect(runnerResult).toEqual(generateRunnerResult({ successRatio: 0 })); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'failure', - task: true, - reason: 'unknown', - status: 'error', - consumer: 'bar', - }) - ); + testAlertingEventLogCalls({ + setRuleName: false, + status: 'error', + errorReason: 'unknown', + executionStatus: 'not-reached', + }); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -1792,6 +1659,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -1799,20 +1667,13 @@ describe('Task Runner', () => { expect(runnerResult).toEqual(generateRunnerResult({ successRatio: 0 })); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'failure', - task: true, - reason: 'read', - status: 'error', - consumer: 'bar', - }) - ); + testAlertingEventLogCalls({ + setRuleName: false, + status: 'error', + errorReason: 'read', + executionStatus: 'not-reached', + }); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -1831,6 +1692,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); @@ -1868,6 +1730,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -1897,6 +1760,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -1927,6 +1791,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -1950,6 +1815,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -1975,6 +1841,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -2022,6 +1889,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', @@ -2030,76 +1899,53 @@ describe('Task Runner', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 2, + newAlerts: 2, + status: 'active', + logAlert: 4, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', + generateAlertOpts({ + action: EVENT_LOG_ACTIONS.newInstance, + group: 'default', + state: { + start: DATE_1970, + duration: '0', + }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 2, - generateEventLog({ - duration: '0', - start: DATE_1970, + generateAlertOpts({ + id: '2', action: EVENT_LOG_ACTIONS.newInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { + start: DATE_1970, + duration: '0', + }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 3, - generateEventLog({ - duration: '0', - start: DATE_1970, - action: EVENT_LOG_ACTIONS.newInstance, - actionGroupId: 'default', - instanceId: '2', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - duration: '0', - start: DATE_1970, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 5, - generateEventLog({ - duration: '0', - start: DATE_1970, + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 4, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - instanceId: '2', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 6, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - numberOfActiveAlerts: 2, - numberOfNewAlerts: 2, - task: true, - consumer: 'bar', + id: '2', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -2149,6 +1995,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', @@ -2157,51 +2005,26 @@ describe('Task Runner', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 2, + status: 'active', + logAlert: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - duration: MOCK_DURATION, - start: DATE_1969, - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { bar: false, start: DATE_1969, duration: MOCK_DURATION }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - duration: '64800000000000', - start: '1969-12-31T06:00:00.000Z', - instanceId: '2', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - numberOfActiveAlerts: 2, - task: true, - consumer: 'bar', + id: '2', + group: 'default', + state: { bar: false, start: '1969-12-31T06:00:00.000Z', duration: '64800000000000' }, }) ); @@ -2246,6 +2069,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', @@ -2254,48 +2079,29 @@ describe('Task Runner', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + activeAlerts: 2, + status: 'active', + logAlert: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { bar: false }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - consumer: 'bar', - instanceId: '2', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'active', - consumer: 'bar', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - numberOfActiveAlerts: 2, - task: true, + id: '2', + group: 'default', + state: { bar: false }, }) ); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -2332,6 +2138,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', @@ -2340,53 +2148,32 @@ describe('Task Runner', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + recoveredAlerts: 2, + status: 'ok', + logAlert: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ + generateAlertOpts({ action: EVENT_LOG_ACTIONS.recoveredInstance, - duration: MOCK_DURATION, - start: DATE_1969, - end: DATE_1970, - consumer: 'bar', - instanceId: '1', + state: { bar: false, start: DATE_1969, end: DATE_1970, duration: MOCK_DURATION }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.recoveredInstance, - duration: '64800000000000', - start: '1969-12-31T06:00:00.000Z', - end: DATE_1970, - consumer: 'bar', - instanceId: '2', + id: '2', + state: { + bar: false, + start: '1969-12-31T06:00:00.000Z', + end: DATE_1970, + duration: '64800000000000', + }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'ok', - consumer: 'bar', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - numberOfRecoveredAlerts: 2, - task: true, - }) - ); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -2425,6 +2212,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue({ ...mockedRuleTypeSavedObject, notifyWhen: 'onActionGroupChange', @@ -2433,47 +2222,29 @@ describe('Task Runner', () => { encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + recoveredAlerts: 2, + status: 'ok', + logAlert: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ + generateAlertOpts({ action: EVENT_LOG_ACTIONS.recoveredInstance, - consumer: 'bar', - instanceId: '1', + state: { bar: false }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.recoveredInstance, - consumer: 'bar', - instanceId: '2', + id: '2', + state: { + bar: false, + }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'ok', - consumer: 'bar', - numberOfTriggeredActions: 0, - numberOfGeneratedActions: 0, - numberOfRecoveredAlerts: 2, - task: true, - }) - ); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -2493,6 +2264,8 @@ describe('Task Runner', () => { }, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); const runnerResult = await taskRunner.run(); @@ -2539,17 +2312,10 @@ describe('Task Runner', () => { 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"triggeredActionsStatus":"complete"}' ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); + testAlertingEventLogCalls({ + status: 'ok', + }); + expect( taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update ).toHaveBeenCalledWith(...generateSavedObjectParams({})); @@ -2570,6 +2336,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ ...SAVED_OBJECT, @@ -2579,28 +2347,14 @@ describe('Task Runner', () => { expect(runnerResult.state.previousStartedAt?.toISOString()).toBe(state.previousStartedAt); expect(runnerResult.schedule).toStrictEqual(mockedTaskInstance.schedule); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - errorMessage: 'Rule failed to execute because rule ran after it was disabled.', - action: EVENT_LOG_ACTIONS.execute, - consumer: 'bar', - outcome: 'failure', - task: true, - reason: 'disabled', - status: 'error', - }) - ); + testAlertingEventLogCalls({ + setRuleName: false, + status: 'error', + errorReason: 'disabled', + errorMessage: `Rule failed to execute because rule ran after it was disabled.`, + executionStatus: 'not-reached', + }); + expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -2611,6 +2365,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); @@ -2625,6 +2380,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(SAVED_OBJECT); ruleType.executor.mockImplementation( @@ -2651,6 +2408,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); await taskRunner.run(); @@ -2684,6 +2443,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue(SAVED_OBJECT); @@ -2767,6 +2528,7 @@ describe('Task Runner', () => { }, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); const runnerResult = await taskRunner.run(); @@ -2805,87 +2567,40 @@ describe('Task Runner', () => { 'Rule "1" skipped scheduling action "4" because the maximum number of allowed actions has been reached.' ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(7); - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( + testAlertingEventLogCalls({ + newAlerts: 1, + activeAlerts: 1, + triggeredActions: actionsConfigMap.default.max, + generatedActions: mockActions.length, + status: 'warning', + errorReason: `maxExecutableActions`, + logAlert: 2, + logAction: 3, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( 1, - generateEventLog({ - task: true, - action: EVENT_LOG_ACTIONS.executeStart, - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 2, - generateEventLog({ - duration: '0', - start: DATE_1970, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.newInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 3, - generateEventLog({ - duration: '0', - start: DATE_1970, + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ action: EVENT_LOG_ACTIONS.activeInstance, - actionGroupId: 'default', - instanceId: '1', - consumer: 'bar', - }) - ); - - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 4, - generateEventLog({ - action: EVENT_LOG_ACTIONS.executeAction, - savedObjects: [generateAlertSO('1'), generateActionSO('1')], - actionGroupId: 'default', - instanceId: '1', - actionId: '1', - consumer: 'bar', - }) - ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 5, - generateEventLog({ - action: EVENT_LOG_ACTIONS.executeAction, - savedObjects: [generateAlertSO('1'), generateActionSO('2')], - actionGroupId: 'default', - instanceId: '1', - actionId: '2', - consumer: 'bar', + group: 'default', + state: { start: DATE_1970, duration: '0' }, }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 6, - generateEventLog({ - action: EVENT_LOG_ACTIONS.executeAction, - savedObjects: [generateAlertSO('1'), generateActionSO('3')], - actionGroupId: 'default', - instanceId: '1', - actionId: '3', - consumer: 'bar', - }) + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, generateActionOpts({})); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith( + 2, + generateActionOpts({ id: '2' }) ); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith( - 7, - generateEventLog({ - action: EVENT_LOG_ACTIONS.execute, - outcome: 'success', - status: 'warning', - numberOfTriggeredActions: actionsConfigMap.default.max, - numberOfGeneratedActions: mockActions.length, - numberOfActiveAlerts: 1, - numberOfNewAlerts: 1, - reason: RuleExecutionStatusWarningReasons.MAX_EXECUTABLE_ACTIONS, - task: true, - consumer: 'bar', - }) + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith( + 3, + generateActionOpts({ id: '3' }) ); }); @@ -2966,6 +2681,7 @@ describe('Task Runner', () => { }, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); const runnerResult = await taskRunner.run(); @@ -3017,8 +2733,16 @@ describe('Task Runner', () => { 'Rule "1" skipped scheduling action "1" because the maximum number of allowed actions for connector type .server-log has been reached.' ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(11); + testAlertingEventLogCalls({ + newAlerts: 2, + activeAlerts: 2, + generatedActions: 10, + triggeredActions: 5, + status: 'warning', + errorReason: `maxExecutableActions`, + logAlert: 4, + logAction: 5, + }); }); test('increments monitoring metrics after execution', async () => { @@ -3028,6 +2752,8 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalled(); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValue({ id: '1', @@ -3067,4 +2793,125 @@ describe('Task Runner', () => { expect(inMemoryMetrics.increment.mock.calls[4][0]).toBe(IN_MEMORY_METRICS.RULE_FAILURES); expect(inMemoryMetrics.increment.mock.calls[5][0]).toBe(IN_MEMORY_METRICS.RULE_TIMEOUTS); }); + + function testAlertingEventLogCalls({ + ruleContext = alertingEventLoggerInitializer, + activeAlerts = 0, + newAlerts = 0, + recoveredAlerts = 0, + triggeredActions = 0, + generatedActions = 0, + status, + errorReason, + errorMessage = 'GENERIC ERROR MESSAGE', + executionStatus = 'succeeded', + setRuleName = true, + logAlert = 0, + logAction = 0, + }: { + status: string; + ruleContext?: RuleContextOpts; + activeAlerts?: number; + newAlerts?: number; + recoveredAlerts?: number; + triggeredActions?: number; + generatedActions?: number; + executionStatus?: 'succeeded' | 'failed' | 'not-reached'; + setRuleName?: boolean; + logAlert?: number; + logAction?: number; + errorReason?: string; + errorMessage?: string; + }) { + expect(alertingEventLogger.initialize).toHaveBeenCalledWith(ruleContext); + expect(alertingEventLogger.start).toHaveBeenCalled(); + if (setRuleName) { + expect(alertingEventLogger.setRuleName).toHaveBeenCalledWith(mockedRuleTypeSavedObject.name); + } else { + expect(alertingEventLogger.setRuleName).not.toHaveBeenCalled(); + } + expect(alertingEventLogger.getStartAndDuration).toHaveBeenCalled(); + if (status === 'error') { + expect(alertingEventLogger.done).toHaveBeenCalledWith({ + metrics: null, + status: { + lastExecutionDate: new Date('1970-01-01T00:00:00.000Z'), + status, + error: { + message: errorMessage, + reason: errorReason, + }, + }, + }); + } else if (status === 'warning') { + expect(alertingEventLogger.done).toHaveBeenCalledWith({ + metrics: { + esSearchDurationMs: 33, + numSearches: 3, + numberOfActiveAlerts: activeAlerts, + numberOfGeneratedActions: generatedActions, + numberOfNewAlerts: newAlerts, + numberOfRecoveredAlerts: recoveredAlerts, + numberOfTriggeredActions: triggeredActions, + totalSearchDurationMs: 23423, + triggeredActionsStatus: 'partial', + }, + status: { + lastExecutionDate: new Date('1970-01-01T00:00:00.000Z'), + status, + warning: { + message: `The maximum number of actions for this rule type was reached; excess actions were not triggered.`, + reason: errorReason, + }, + }, + }); + } else { + expect(alertingEventLogger.done).toHaveBeenCalledWith({ + metrics: { + esSearchDurationMs: 33, + numSearches: 3, + numberOfActiveAlerts: activeAlerts, + numberOfGeneratedActions: generatedActions, + numberOfNewAlerts: newAlerts, + numberOfRecoveredAlerts: recoveredAlerts, + numberOfTriggeredActions: triggeredActions, + totalSearchDurationMs: 23423, + triggeredActionsStatus: 'complete', + }, + status: { + lastExecutionDate: new Date('1970-01-01T00:00:00.000Z'), + status, + }, + }); + } + + if (executionStatus === 'succeeded') { + expect(alertingEventLogger.setExecutionSucceeded).toHaveBeenCalledWith( + `rule executed: test:1: 'rule-name'` + ); + expect(alertingEventLogger.setExecutionFailed).not.toHaveBeenCalled(); + } else if (executionStatus === 'failed') { + expect(alertingEventLogger.setExecutionFailed).toHaveBeenCalledWith( + `rule execution failure: test:1: 'rule-name'`, + errorMessage + ); + expect(alertingEventLogger.setExecutionSucceeded).not.toHaveBeenCalled(); + } else if (executionStatus === 'not-reached') { + expect(alertingEventLogger.setExecutionSucceeded).not.toHaveBeenCalled(); + expect(alertingEventLogger.setExecutionFailed).not.toHaveBeenCalled(); + } + + if (logAlert > 0) { + expect(alertingEventLogger.logAlert).toHaveBeenCalledTimes(logAlert); + } else { + expect(alertingEventLogger.logAlert).not.toHaveBeenCalled(); + } + + if (logAction > 0) { + expect(alertingEventLogger.logAction).toHaveBeenCalledTimes(logAction); + } else { + expect(alertingEventLogger.logAction).not.toHaveBeenCalled(); + } + expect(alertingEventLogger.logTimeout).not.toHaveBeenCalled(); + } }); 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 afed1f4c9ad09ac..6cd6b73b9539ea2 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -5,19 +5,14 @@ * 2.0. */ import apm from 'elastic-apm-node'; -import { cloneDeep, mapValues, omit, pickBy, set, without } from 'lodash'; +import { cloneDeep, mapValues, omit, pickBy, without } from 'lodash'; import type { Request } from '@hapi/hapi'; import { UsageCounter } from '@kbn/usage-collection-plugin/server'; import uuid from 'uuid'; import { addSpaceIdToPath } from '@kbn/spaces-plugin/server'; import { KibanaRequest, Logger } from '@kbn/core/server'; import { ConcreteTaskInstance, throwUnrecoverableError } from '@kbn/task-manager-plugin/server'; -import { - IEvent, - SAVED_OBJECT_REL_PRIMARY, - millisToNanos, - nanosToMillis, -} from '@kbn/event-log-plugin/server'; +import { millisToNanos, nanosToMillis } from '@kbn/event-log-plugin/server'; import { TaskRunnerContext } from './task_runner_factory'; import { createExecutionHandler, ExecutionHandler } from './create_execution_handler'; import { Alert, createAlertFactory } from '../alert'; @@ -62,10 +57,6 @@ import { } from '../../common'; import { NormalizedRuleType, UntypedNormalizedRuleType } from '../rule_type_registry'; import { getEsErrorMessage } from '../lib/errors'; -import { - createAlertEventLogRecordObject, - Event, -} from '../lib/create_alert_event_log_record_object'; import { InMemoryMetrics, IN_MEMORY_METRICS } from '../monitoring'; import { GenerateNewAndRecoveredAlertEventsParams, @@ -79,13 +70,11 @@ import { } from './types'; import { IExecutionStatusAndMetrics } from '../lib/rule_execution_status'; import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; +import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; const FALLBACK_RETRY_INTERVAL = '5m'; const CONNECTIVITY_RETRY_INTERVAL = '5m'; -// 1,000,000 nanoseconds in 1 millisecond -const Millis2Nanos = 1000 * 1000; - export const getDefaultRuleMonitoring = (): RuleMonitoring => ({ execution: { history: [], @@ -107,7 +96,6 @@ export class TaskRunner< private context: TaskRunnerContext; private logger: Logger; private taskInstance: RuleTaskInstance; - private ruleName: string | null; private ruleConsumer: string | null; private ruleType: NormalizedRuleType< Params, @@ -121,6 +109,7 @@ export class TaskRunner< private readonly executionId: string; private readonly ruleTypeRegistry: RuleTypeRegistry; private readonly inMemoryMetrics: InMemoryMetrics; + private alertingEventLogger: AlertingEventLogger; private usageCounter?: UsageCounter; private searchAbortController: AbortController; private cancelled: boolean; @@ -143,7 +132,6 @@ export class TaskRunner< this.logger = context.logger; this.usageCounter = context.usageCounter; this.ruleType = ruleType; - this.ruleName = null; this.ruleConsumer = null; this.taskInstance = taskInstanceToAlertTaskInstance(taskInstance); this.ruleTypeRegistry = context.ruleTypeRegistry; @@ -151,6 +139,7 @@ export class TaskRunner< this.cancelled = false; this.executionId = uuid.v4(); this.inMemoryMetrics = inMemoryMetrics; + this.alertingEventLogger = new AlertingEventLogger(this.context.eventLogger); } private async getDecryptedAttributes( @@ -231,7 +220,7 @@ export class TaskRunner< spaceId, ruleType: this.ruleType, kibanaBaseUrl, - eventLogger: this.context.eventLogger, + alertingEventLogger: this.alertingEventLogger, request, ruleParams, supportsEphemeralTasks: this.context.supportsEphemeralTasks, @@ -321,8 +310,7 @@ export class TaskRunner< rule: SanitizedRule, params: Params, executionHandler: ExecutionHandler, - spaceId: string, - event: Event + spaceId: string ): Promise { const { alertTypeId, @@ -358,7 +346,6 @@ export class TaskRunner< const originalAlerts = cloneDeep(alerts); const originalAlertIds = new Set(Object.keys(originalAlerts)); - const eventLogger = this.context.eventLogger; const ruleLabel = `${this.ruleType.id}:${ruleId}: '${name}'`; const scopedClusterClient = this.context.elasticsearch.client.asScoped(fakeRequest); @@ -440,22 +427,15 @@ export class TaskRunner< }) ); } catch (err) { - event.message = `rule execution failure: ${ruleLabel}`; - event.error = event.error || {}; - event.error.message = err.message; - event.event = event.event || {}; - event.event.outcome = 'failure'; + this.alertingEventLogger.setExecutionFailed( + `rule execution failure: ${ruleLabel}`, + err.message + ); throw new ErrorWithReason(RuleExecutionStatusErrorReasons.Execute, err); } - event.message = `rule executed: ${ruleLabel}`; - event.event = event.event || {}; - event.event.outcome = 'success'; - event.rule = { - ...event.rule, - name: rule.name, - }; + this.alertingEventLogger.setExecutionSucceeded(`rule executed: ${ruleLabel}`); const ruleRunMetricsStore = new RuleRunMetricsStore(); @@ -488,17 +468,11 @@ export class TaskRunner< if (this.shouldLogAndScheduleActionsForAlerts()) { generateNewAndRecoveredAlertEvents({ - eventLogger, - executionId: this.executionId, + alertingEventLogger: this.alertingEventLogger, originalAlerts, currentAlerts: alertsWithScheduledActions, recoveredAlerts, - ruleId, ruleLabel, - namespace, - ruleType, - rule, - spaceId, ruleRunMetricsStore, }); } @@ -584,8 +558,7 @@ export class TaskRunner< private async validateAndExecuteRule( fakeRequest: KibanaRequest, apiKey: RawRule['apiKey'], - rule: SanitizedRule, - event: Event + rule: SanitizedRule ) { const { params: { alertId: ruleId, spaceId }, @@ -604,10 +577,10 @@ export class TaskRunner< rule.params, fakeRequest ); - return this.executeRule(fakeRequest, rule, validatedParams, executionHandler, spaceId, event); + return this.executeRule(fakeRequest, rule, validatedParams, executionHandler, spaceId); } - private async loadRuleAttributesAndRun(event: Event): Promise> { + private async loadRuleAttributesAndRun(): Promise> { const { params: { alertId: ruleId, spaceId }, } = this.taskInstance; @@ -657,7 +630,7 @@ export class TaskRunner< throw new ErrorWithReason(RuleExecutionStatusErrorReasons.Read, err); } - this.ruleName = rule.name; + this.alertingEventLogger.setRuleName(rule.name); try { this.ruleTypeRegistry.ensureRuleTypeEnabled(rule.alertTypeId); @@ -674,7 +647,7 @@ export class TaskRunner< return { monitoring: asOk(rule.monitoring), stateWithMetrics: await promiseResult( - this.validateAndExecuteRule(fakeRequest, apiKey, rule, event) + this.validateAndExecuteRule(fakeRequest, apiKey, rule) ), schedule: asOk( // fetch the rule again to ensure we return the correct schedule as it may have @@ -716,46 +689,21 @@ export class TaskRunner< this.logger.debug(`executing rule ${this.ruleType.id}:${ruleId} at ${runDateString}`); const namespace = this.context.spaceIdToNamespace(spaceId); - const eventLogger = this.context.eventLogger; - const scheduleDelay = runDate.getTime() - this.taskInstance.scheduledAt.getTime(); - const event = createAlertEventLogRecordObject({ + this.alertingEventLogger.initialize({ ruleId, ruleType: this.ruleType as UntypedNormalizedRuleType, consumer: this.ruleConsumer!, - action: EVENT_LOG_ACTIONS.execute, - namespace, spaceId, executionId: this.executionId, - task: { - scheduled: this.taskInstance.scheduledAt.toISOString(), - scheduleDelay: Millis2Nanos * scheduleDelay, - }, - savedObjects: [ - { - id: ruleId, - type: 'alert', - typeId: this.ruleType.id, - relation: SAVED_OBJECT_REL_PRIMARY, - }, - ], - }); - - eventLogger.startTiming(event); - - const startEvent = cloneDeep({ - ...event, - event: { - ...event.event, - action: EVENT_LOG_ACTIONS.executeStart, - }, - message: `rule execution start: "${ruleId}"`, + taskScheduledAt: this.taskInstance.scheduledAt, + ...(namespace ? { namespace } : {}), }); - eventLogger.logEvent(startEvent); + this.alertingEventLogger.start(); const { stateWithMetrics, schedule, monitoring } = await errorAsRuleTaskRunResult( - this.loadRuleAttributesAndRun(event) + this.loadRuleAttributesAndRun() ); const ruleMonitoring = @@ -772,10 +720,6 @@ export class TaskRunner< (ruleRunStateWithMetrics) => executionStatusFromState(ruleRunStateWithMetrics, runDate), (err: ElasticsearchError) => executionStatusFromError(err, runDate) ); - // set the executionStatus date to same as event, if it's set - if (event.event?.start) { - executionStatus.lastExecutionDate = new Date(event.event.start); - } if (apm.currentTransaction) { if (executionStatus.status === 'ok' || executionStatus.status === 'active') { @@ -794,91 +738,27 @@ export class TaskRunner< ); } - eventLogger.stopTiming(event); - set(event, 'kibana.alerting.status', executionStatus.status); - - if (this.ruleConsumer) { - set(event, 'kibana.alert.rule.consumer', this.ruleConsumer); - } + this.alertingEventLogger.done({ status: executionStatus, metrics: executionMetrics }); const monitoringHistory: RuleMonitoringHistory = { success: true, timestamp: +new Date(), }; - // Copy duration into execution status if available - if (null != event.event?.duration) { - executionStatus.lastDuration = nanosToMillis(event.event?.duration); + // set start and duration based on event log + const { start, duration } = this.alertingEventLogger.getStartAndDuration(); + if (null != start) { + executionStatus.lastExecutionDate = start; + } + if (null != duration) { + executionStatus.lastDuration = nanosToMillis(duration); monitoringHistory.duration = executionStatus.lastDuration; } // if executionStatus indicates an error, fill in fields in // event from it if (executionStatus.error) { - set(event, 'event.reason', executionStatus.error?.reason || 'unknown'); - set(event, 'event.outcome', 'failure'); - set(event, 'error.message', event?.error?.message || executionStatus.error.message); - if (!event.message) { - event.message = `${this.ruleType.id}:${ruleId}: execution failed`; - } monitoringHistory.success = false; - } else { - if (executionStatus.warning) { - set(event, 'event.reason', executionStatus.warning?.reason || 'unknown'); - set(event, 'message', executionStatus.warning?.message || event?.message); - } - if (executionMetrics) { - set( - event, - 'kibana.alert.rule.execution.metrics.number_of_triggered_actions', - executionMetrics.numberOfTriggeredActions - ); - set( - event, - 'kibana.alert.rule.execution.metrics.number_of_generated_actions', - executionMetrics.numberOfGeneratedActions - ); - set( - event, - 'kibana.alert.rule.execution.metrics.number_of_active_alerts', - executionMetrics.numberOfActiveAlerts - ); - set( - event, - 'kibana.alert.rule.execution.metrics.number_of_new_alerts', - executionMetrics.numberOfNewAlerts - ); - set( - event, - 'kibana.alert.rule.execution.metrics.total_number_of_alerts', - (executionMetrics.numberOfActiveAlerts ?? 0) + - (executionMetrics.numberOfRecoveredAlerts ?? 0) - ); - set( - event, - 'kibana.alert.rule.execution.metrics.number_of_recovered_alerts', - executionMetrics.numberOfRecoveredAlerts - ); - } - } - - // Copy search stats into event log - if (executionMetrics) { - set( - event, - 'kibana.alert.rule.execution.metrics.number_of_searches', - executionMetrics.numSearches ?? 0 - ); - set( - event, - 'kibana.alert.rule.execution.metrics.es_search_duration_ms', - executionMetrics.esSearchDurationMs ?? 0 - ); - set( - event, - 'kibana.alert.rule.execution.metrics.total_search_duration_ms', - executionMetrics.totalSearchDurationMs ?? 0 - ); } ruleMonitoring.execution.history.push(monitoringHistory); @@ -887,8 +767,6 @@ export class TaskRunner< ...getExecutionDurationPercentiles(ruleMonitoring), }; - eventLogger.logEvent(event); - if (!this.cancelled) { this.inMemoryMetrics.increment(IN_MEMORY_METRICS.RULE_EXECUTIONS); if (executionStatus.error) { @@ -982,48 +860,7 @@ export class TaskRunner< ); this.searchAbortController.abort(); - const eventLogger = this.context.eventLogger; - const event: IEvent = { - event: { - action: EVENT_LOG_ACTIONS.executeTimeout, - kind: 'alert', - category: [this.ruleType.producer], - }, - message: `rule: ${this.ruleType.id}:${ruleId}: '${ - this.ruleName ?? '' - }' execution cancelled due to timeout - exceeded rule type timeout of ${ - this.ruleType.ruleTaskTimeout - }`, - kibana: { - alert: { - rule: { - ...(this.ruleConsumer ? { consumer: this.ruleConsumer } : {}), - execution: { - uuid: this.executionId, - }, - rule_type_id: this.ruleType.id, - }, - }, - saved_objects: [ - { - rel: SAVED_OBJECT_REL_PRIMARY, - type: 'alert', - id: ruleId, - type_id: this.ruleType.id, - namespace, - }, - ], - space_ids: [spaceId], - }, - rule: { - id: ruleId, - license: this.ruleType.minimumLicenseRequired, - category: this.ruleType.id, - ruleset: this.ruleType.producer, - ...(this.ruleName ? { name: this.ruleName } : {}), - }, - }; - eventLogger.logEvent(event); + this.alertingEventLogger.logTimeout(); this.inMemoryMetrics.increment(IN_MEMORY_METRICS.RULE_TIMEOUTS); @@ -1096,16 +933,10 @@ function generateNewAndRecoveredAlertEvents< InstanceContext extends AlertInstanceContext >(params: GenerateNewAndRecoveredAlertEventsParams) { const { - eventLogger, - executionId, - ruleId, - namespace, + alertingEventLogger, currentAlerts, originalAlerts, recoveredAlerts, - rule, - ruleType, - spaceId, ruleRunMetricsStore, } = params; const originalAlertIds = Object.keys(originalAlerts); @@ -1128,14 +959,15 @@ function generateNewAndRecoveredAlertEvents< recoveredAlerts[id].getLastScheduledActions() ?? {}; const state = recoveredAlerts[id].getState(); const message = `${params.ruleLabel} alert '${id}' has recovered`; - logAlertEvent( + + alertingEventLogger.logAlert({ + action: EVENT_LOG_ACTIONS.recoveredInstance, id, - EVENT_LOG_ACTIONS.recoveredInstance, + group: actionGroup, + subgroup: actionSubgroup, message, state, - actionGroup, - actionSubgroup - ); + }); } for (const id of newIds) { @@ -1143,7 +975,14 @@ function generateNewAndRecoveredAlertEvents< currentAlerts[id].getScheduledActionOptions() ?? {}; const state = currentAlerts[id].getState(); const message = `${params.ruleLabel} created new alert: '${id}'`; - logAlertEvent(id, EVENT_LOG_ACTIONS.newInstance, message, state, actionGroup, actionSubgroup); + alertingEventLogger.logAlert({ + action: EVENT_LOG_ACTIONS.newInstance, + id, + group: actionGroup, + subgroup: actionSubgroup, + message, + state, + }); } for (const id of currentAlertIds) { @@ -1155,69 +994,14 @@ function generateNewAndRecoveredAlertEvents< ? `actionGroup(subgroup): '${actionGroup}(${actionSubgroup})'` : `actionGroup: '${actionGroup}'` }`; - logAlertEvent( + alertingEventLogger.logAlert({ + action: EVENT_LOG_ACTIONS.activeInstance, id, - EVENT_LOG_ACTIONS.activeInstance, + group: actionGroup, + subgroup: actionSubgroup, message, state, - actionGroup, - actionSubgroup - ); - } - - function logAlertEvent( - alertId: string, - action: string, - message: string, - state: InstanceState, - group?: string, - subgroup?: string - ) { - const event: IEvent = { - event: { - action, - kind: 'alert', - category: [ruleType.producer], - ...(state?.start ? { start: state.start as string } : {}), - ...(state?.end ? { end: state.end as string } : {}), - ...(state?.duration !== undefined ? { duration: state.duration as string } : {}), - }, - kibana: { - alert: { - rule: { - consumer: rule.consumer, - execution: { - uuid: executionId, - }, - rule_type_id: ruleType.id, - }, - }, - alerting: { - instance_id: alertId, - ...(group ? { action_group_id: group } : {}), - ...(subgroup ? { action_subgroup: subgroup } : {}), - }, - saved_objects: [ - { - rel: SAVED_OBJECT_REL_PRIMARY, - type: 'alert', - id: ruleId, - type_id: ruleType.id, - namespace, - }, - ], - space_ids: [spaceId], - }, - message, - rule: { - id: rule.id, - license: ruleType.minimumLicenseRequired, - category: ruleType.id, - ruleset: ruleType.producer, - name: rule.name, - }, - }; - eventLogger.logEvent(event); + }); } } diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts index fdb3c8e75600308..fb2d1be3a3872ce 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_cancel.test.ts @@ -14,7 +14,7 @@ import { AlertInstanceState, AlertInstanceContext, } from '../types'; -import { ConcreteTaskInstance, TaskStatus } from '@kbn/task-manager-plugin/server'; +import { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server'; import { TaskRunnerContext } from './task_runner_factory'; import { TaskRunner } from './task_runner'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; @@ -32,11 +32,23 @@ import { actionsMock, actionsClientMock } from '@kbn/actions-plugin/server/mocks import { alertsMock, rulesClientMock } from '../mocks'; import { eventLoggerMock } from '@kbn/event-log-plugin/server/event_logger.mock'; import { IEventLogger } from '@kbn/event-log-plugin/server'; -import { Rule, RecoveredActionGroup } from '../../common'; -import { UntypedNormalizedRuleType } from '../rule_type_registry'; import { ruleTypeRegistryMock } from '../rule_type_registry.mock'; import { dataPluginMock } from '@kbn/data-plugin/server/mocks'; import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock'; +import { + AlertingEventLogger, + RuleContextOpts, +} from '../lib/alerting_event_logger/alerting_event_logger'; +import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; +import { + mockTaskInstance, + ruleType, + mockedRuleTypeSavedObject, + generateAlertOpts, + DATE_1970, + generateActionOpts, +} from './fixtures'; +import { EVENT_LOG_ACTIONS } from '../plugin'; jest.mock('uuid', () => ({ v4: () => '5f6aa57d-3e22-484e-bae8-cbed868f4d28', @@ -45,48 +57,29 @@ jest.mock('../lib/wrap_scoped_cluster_client', () => ({ createWrappedScopedClusterClientFactory: jest.fn(), })); -const ruleType: jest.Mocked = { - id: 'test', - name: 'My test rule', - actionGroups: [{ id: 'default', name: 'Default' }, RecoveredActionGroup], - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - isExportable: true, - recoveryActionGroup: RecoveredActionGroup, - executor: jest.fn(), - producer: 'alerts', - cancelAlertsOnRuleTimeout: true, - ruleTaskTimeout: '5m', -}; +jest.mock('../lib/alerting_event_logger/alerting_event_logger'); let fakeTimer: sinon.SinonFakeTimers; const mockUsageCountersSetup = usageCountersServiceMock.createSetupContract(); const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test'); +const alertingEventLogger = alertingEventLoggerMock.create(); describe('Task Runner Cancel', () => { let mockedTaskInstance: ConcreteTaskInstance; + let alertingEventLoggerInitializer: RuleContextOpts; beforeAll(() => { fakeTimer = sinon.useFakeTimers(); - mockedTaskInstance = { - id: '', - attempts: 0, - status: TaskStatus.Running, - version: '123', - runAt: new Date(), - schedule: { interval: '10s' }, - scheduledAt: new Date(), - startedAt: new Date(), - retryAt: new Date(Date.now() + 5 * 60 * 1000), - state: {}, - taskType: 'alerting:test', - params: { - alertId: '1', - spaceId: 'default', - consumer: 'bar', - }, - ownerId: null, + mockedTaskInstance = mockTaskInstance(); + + alertingEventLoggerInitializer = { + consumer: mockedTaskInstance.params.consumer, + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + ruleId: mockedTaskInstance.params.alertId, + ruleType, + spaceId: mockedTaskInstance.params.spaceId, + taskScheduledAt: mockedTaskInstance.scheduledAt, }; }); @@ -136,53 +129,6 @@ describe('Task Runner Cancel', () => { }, }; - const mockDate = new Date('2019-02-12T21:01:22.479Z'); - - const mockedRuleSavedObject: Rule = { - id: '1', - consumer: 'bar', - createdAt: mockDate, - updatedAt: mockDate, - throttle: null, - muteAll: false, - notifyWhen: 'onActiveAlert', - enabled: true, - alertTypeId: ruleType.id, - apiKey: '', - apiKeyOwner: 'elastic', - schedule: { interval: '10s' }, - name: 'rule-name', - tags: ['rule-', '-tags'], - createdBy: 'rule-creator', - updatedBy: 'rule-updater', - mutedInstanceIds: [], - params: { - bar: true, - }, - actions: [ - { - group: 'default', - id: '1', - actionTypeId: 'action', - params: { - foo: true, - }, - }, - { - group: RecoveredActionGroup.id, - id: '2', - actionTypeId: 'action', - params: { - isResolved: true, - }, - }, - ], - executionStatus: { - status: 'unknown', - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - }, - }; - beforeEach(() => { jest.resetAllMocks(); jest @@ -208,7 +154,7 @@ describe('Task Runner Cancel', () => { taskRunnerFactoryInitializerParams.executionContext.withContext.mockImplementation((ctx, fn) => fn() ); - rulesClient.get.mockResolvedValue(mockedRuleSavedObject); + rulesClient.get.mockResolvedValue(mockedRuleTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -221,6 +167,8 @@ describe('Task Runner Cancel', () => { }); taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true); taskRunnerFactoryInitializerParams.actionsPlugin.isActionExecutable.mockReturnValue(true); + alertingEventLogger.getStartAndDuration.mockImplementation(() => ({ start: new Date() })); + (AlertingEventLogger as jest.Mock).mockImplementation(() => alertingEventLogger); }); test('updates rule saved object execution status and writes to event log entry when task is cancelled mid-execution', async () => { @@ -230,6 +178,7 @@ describe('Task Runner Cancel', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); const promise = taskRunner.run(); await Promise.resolve(); @@ -242,136 +191,7 @@ describe('Task Runner Cancel', () => { `Aborting any in-progress ES searches for rule type test with id 1` ); - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - // execute-start event, timeout event and then an execute event because rule executors are not cancelling anything yet - expect(eventLogger.logEvent).toHaveBeenCalledTimes(3); - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { - event: { - action: 'execute-start', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - saved_objects: [ - { - id: '1', - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - task: { - schedule_delay: 0, - scheduled: '1970-01-01T00:00:00.000Z', - }, - }, - message: 'rule execution start: "1"', - rule: { - category: 'test', - id: '1', - license: 'basic', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(2, { - event: { - action: 'execute-timeout', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - saved_objects: [ - { - id: '1', - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: `rule: test:1: '' execution cancelled due to timeout - exceeded rule type timeout of 5m`, - rule: { - category: 'test', - id: '1', - license: 'basic', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(3, { - event: { - action: 'execute', - category: ['alerts'], - kind: 'alert', - outcome: 'success', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - metrics: { - number_of_searches: 3, - number_of_triggered_actions: 0, - number_of_generated_actions: 0, - number_of_active_alerts: 0, - number_of_new_alerts: 0, - number_of_recovered_alerts: 0, - total_number_of_alerts: 0, - es_search_duration_ms: 33, - total_search_duration_ms: 23423, - }, - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - alerting: { - status: 'ok', - }, - saved_objects: [ - { - id: '1', - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - task: { - schedule_delay: 0, - scheduled: '1970-01-01T00:00:00.000Z', - }, - }, - message: `rule executed: test:1: 'rule-name'`, - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'rule-name', - ruleset: 'alerts', - }, - }); + testAlertingEventLogCalls({ status: 'ok' }); expect( taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update @@ -426,22 +246,50 @@ describe('Task Runner Cancel', () => { }, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); const promise = taskRunner.run(); await Promise.resolve(); await taskRunner.cancel(); await promise; - testActionsExecute(); + testLogger(); + testAlertingEventLogCalls({ + status: 'active', + newAlerts: 1, + activeAlerts: 1, + generatedActions: 1, + triggeredActions: 1, + logAction: 1, + logAlert: 2, + }); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 1, + generateAlertOpts({ + action: EVENT_LOG_ACTIONS.newInstance, + group: 'default', + state: { start: DATE_1970, duration: '0' }, + }) + ); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ + action: EVENT_LOG_ACTIONS.activeInstance, + group: 'default', + state: { start: DATE_1970, duration: '0' }, + }) + ); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, generateActionOpts({})); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); test('actionsPlugin.execute is called if rule execution is cancelled but cancelAlertsOnRuleTimeout for ruleType is false', async () => { - ruleTypeRegistry.get.mockReturnValue({ + const updatedRuleType = { ...ruleType, cancelAlertsOnRuleTimeout: false, - }); + }; + ruleTypeRegistry.get.mockReturnValue(updatedRuleType); ruleType.executor.mockImplementation( async ({ services: executorServices, @@ -457,21 +305,47 @@ describe('Task Runner Cancel', () => { ); // setting cancelAlertsOnRuleTimeout for ruleType to false here const taskRunner = new TaskRunner( - { - ...ruleType, - cancelAlertsOnRuleTimeout: false, - }, + updatedRuleType, mockedTaskInstance, taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); const promise = taskRunner.run(); await Promise.resolve(); await taskRunner.cancel(); await promise; - testActionsExecute(); + testLogger(); + testAlertingEventLogCalls({ + ruleContext: { ...alertingEventLoggerInitializer, ruleType: updatedRuleType }, + status: 'active', + activeAlerts: 1, + generatedActions: 1, + newAlerts: 1, + triggeredActions: 1, + logAlert: 2, + logAction: 1, + }); + + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 1, + generateAlertOpts({ + action: EVENT_LOG_ACTIONS.newInstance, + group: 'default', + state: { start: DATE_1970, duration: '0' }, + }) + ); + expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith( + 2, + generateAlertOpts({ + action: EVENT_LOG_ACTIONS.activeInstance, + group: 'default', + state: { start: DATE_1970, duration: '0' }, + }) + ); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, generateActionOpts({})); expect(mockUsageCounter.incrementCounter).not.toHaveBeenCalled(); }); @@ -496,174 +370,15 @@ describe('Task Runner Cancel', () => { taskRunnerFactoryInitializerParams, inMemoryMetrics ); + expect(AlertingEventLogger).toHaveBeenCalledTimes(1); const promise = taskRunner.run(); await Promise.resolve(); await taskRunner.cancel(); await promise; - const logger = taskRunnerFactoryInitializerParams.logger; - expect(logger.debug).toHaveBeenCalledTimes(8); - expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); - expect(logger.debug).nthCalledWith( - 2, - `Cancelling rule type test with id 1 - execution exceeded rule type timeout of 5m` - ); - expect(logger.debug).nthCalledWith( - 3, - 'Aborting any in-progress ES searches for rule type test with id 1' - ); - expect(logger.debug).nthCalledWith( - 4, - `Updating rule task for test rule with id 1 - execution error due to timeout` - ); - expect(logger.debug).nthCalledWith( - 5, - `rule test:1: 'rule-name' has 1 active alerts: [{\"instanceId\":\"1\",\"actionGroup\":\"default\"}]` - ); - expect(logger.debug).nthCalledWith( - 6, - `no scheduling of actions for rule test:1: 'rule-name': rule execution has been cancelled.` - ); - expect(logger.debug).nthCalledWith( - 7, - 'ruleRunStatus for test:1: {"lastExecutionDate":"1970-01-01T00:00:00.000Z","status":"active"}' - ); - expect(logger.debug).nthCalledWith( - 8, - 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":0,"numberOfGeneratedActions":0,"numberOfActiveAlerts":0,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":0,"triggeredActionsStatus":"complete"}' - ); - - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.startTiming).toHaveBeenCalledTimes(1); - expect(eventLogger.logEvent).toHaveBeenCalledTimes(3); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { - event: { - action: 'execute-start', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - task: { - schedule_delay: 0, - scheduled: '1970-01-01T00:00:00.000Z', - }, - saved_objects: [ - { - id: '1', - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: `rule execution start: \"1\"`, - rule: { - category: 'test', - id: '1', - license: 'basic', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(2, { - event: { - action: 'execute-timeout', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: `rule: test:1: '' execution cancelled due to timeout - exceeded rule type timeout of 5m`, - rule: { - category: 'test', - id: '1', - license: 'basic', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(3, { - event: { - action: 'execute', - category: ['alerts'], - kind: 'alert', - outcome: 'success', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - metrics: { - number_of_searches: 3, - number_of_triggered_actions: 0, - number_of_generated_actions: 0, - number_of_active_alerts: 0, - number_of_recovered_alerts: 0, - number_of_new_alerts: 0, - total_number_of_alerts: 0, - es_search_duration_ms: 33, - total_search_duration_ms: 23423, - }, - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - alerting: { - status: 'active', - }, - task: { - schedule_delay: 0, - scheduled: '1970-01-01T00:00:00.000Z', - }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: "rule executed: test:1: 'rule-name'", - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'rule-name', - ruleset: 'alerts', - }, + testAlertingEventLogCalls({ + status: 'active', }); expect(mockUsageCounter.incrementCounter).toHaveBeenCalledTimes(1); @@ -673,7 +388,7 @@ describe('Task Runner Cancel', () => { }); }); - function testActionsExecute() { + function testLogger() { const logger = taskRunnerFactoryInitializerParams.logger; expect(logger.debug).toHaveBeenCalledTimes(7); expect(logger.debug).nthCalledWith(1, 'executing rule test:1 at 1970-01-01T00:00:00.000Z'); @@ -701,256 +416,69 @@ describe('Task Runner Cancel', () => { 7, 'ruleRunMetrics for test:1: {"numSearches":3,"totalSearchDurationMs":23423,"esSearchDurationMs":33,"numberOfTriggeredActions":1,"numberOfGeneratedActions":1,"numberOfActiveAlerts":1,"numberOfRecoveredAlerts":0,"numberOfNewAlerts":1,"triggeredActionsStatus":"complete"}' ); + } - const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(6); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { - event: { - action: 'execute-start', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - task: { - schedule_delay: 0, - scheduled: '1970-01-01T00:00:00.000Z', - }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: `rule execution start: "1"`, - rule: { - category: 'test', - id: '1', - license: 'basic', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(2, { - event: { - action: 'execute-timeout', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: `rule: test:1: '' execution cancelled due to timeout - exceeded rule type timeout of 5m`, - rule: { - category: 'test', - id: '1', - license: 'basic', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(3, { - event: { - action: 'new-instance', - category: ['alerts'], - kind: 'alert', - duration: '0', - start: '1970-01-01T00:00:00.000Z', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - alerting: { - action_group_id: 'default', - instance_id: '1', - }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: "test:1: 'rule-name' created new alert: '1'", - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'rule-name', - namespace: undefined, - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(4, { - event: { - action: 'active-instance', - category: ['alerts'], - duration: '0', - kind: 'alert', - start: '1970-01-01T00:00:00.000Z', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - alerting: { - action_group_id: 'default', - instance_id: '1', - }, - saved_objects: [ - { id: '1', namespace: undefined, rel: 'primary', type: 'alert', type_id: 'test' }, - ], - space_ids: ['default'], - }, - message: "test:1: 'rule-name' active alert: '1' in actionGroup: 'default'", - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'rule-name', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(5, { - event: { - action: 'execute-action', - category: ['alerts'], - kind: 'alert', - }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - alerting: { - instance_id: '1', - action_group_id: 'default', - }, - saved_objects: [ - { - id: '1', - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - { - id: '1', - type: 'action', - type_id: 'action', - }, - ], - space_ids: ['default'], - }, - message: - "alert: test:1: 'rule-name' instanceId: '1' scheduled actionGroup: 'default' action: action:1", - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'rule-name', - ruleset: 'alerts', - }, - }); - expect(eventLogger.logEvent).toHaveBeenNthCalledWith(6, { - event: { action: 'execute', category: ['alerts'], kind: 'alert', outcome: 'success' }, - kibana: { - alert: { - rule: { - consumer: 'bar', - execution: { - metrics: { - number_of_searches: 3, - number_of_triggered_actions: 1, - number_of_generated_actions: 1, - number_of_active_alerts: 1, - number_of_new_alerts: 1, - number_of_recovered_alerts: 0, - total_number_of_alerts: 1, - es_search_duration_ms: 33, - total_search_duration_ms: 23423, - }, - uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - rule_type_id: 'test', - }, - }, - alerting: { - status: 'active', - }, - task: { - schedule_delay: 0, - scheduled: '1970-01-01T00:00:00.000Z', - }, - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - type_id: 'test', - }, - ], - space_ids: ['default'], - }, - message: "rule executed: test:1: 'rule-name'", - rule: { - category: 'test', - id: '1', - license: 'basic', - name: 'rule-name', - ruleset: 'alerts', + function testAlertingEventLogCalls({ + ruleContext = alertingEventLoggerInitializer, + activeAlerts = 0, + newAlerts = 0, + recoveredAlerts = 0, + triggeredActions = 0, + generatedActions = 0, + status, + logAlert = 0, + logAction = 0, + }: { + status: string; + ruleContext?: RuleContextOpts; + activeAlerts?: number; + newAlerts?: number; + recoveredAlerts?: number; + triggeredActions?: number; + generatedActions?: number; + setRuleName?: boolean; + logAlert?: number; + logAction?: number; + }) { + expect(alertingEventLogger.initialize).toHaveBeenCalledWith(ruleContext); + expect(alertingEventLogger.start).toHaveBeenCalled(); + expect(alertingEventLogger.setRuleName).toHaveBeenCalledWith(mockedRuleTypeSavedObject.name); + expect(alertingEventLogger.getStartAndDuration).toHaveBeenCalled(); + + expect(alertingEventLogger.done).toHaveBeenCalledWith({ + metrics: { + esSearchDurationMs: 33, + numSearches: 3, + numberOfActiveAlerts: activeAlerts, + numberOfGeneratedActions: generatedActions, + numberOfNewAlerts: newAlerts, + numberOfRecoveredAlerts: recoveredAlerts, + numberOfTriggeredActions: triggeredActions, + totalSearchDurationMs: 23423, + triggeredActionsStatus: 'complete', + }, + status: { + lastExecutionDate: new Date('1970-01-01T00:00:00.000Z'), + status, }, }); + + expect(alertingEventLogger.setExecutionSucceeded).toHaveBeenCalledWith( + `rule executed: test:1: 'rule-name'` + ); + expect(alertingEventLogger.setExecutionFailed).not.toHaveBeenCalled(); + + if (logAlert > 0) { + expect(alertingEventLogger.logAlert).toHaveBeenCalledTimes(logAlert); + } else { + expect(alertingEventLogger.logAlert).not.toHaveBeenCalled(); + } + + if (logAction > 0) { + expect(alertingEventLogger.logAction).toHaveBeenCalledTimes(logAction); + } else { + expect(alertingEventLogger.logAction).not.toHaveBeenCalled(); + } + expect(alertingEventLogger.logTimeout).toHaveBeenCalled(); } }); diff --git a/x-pack/plugins/alerting/server/task_runner/types.ts b/x-pack/plugins/alerting/server/task_runner/types.ts index 1f4a31fa1d9ac92..d3c6038474a3874 100644 --- a/x-pack/plugins/alerting/server/task_runner/types.ts +++ b/x-pack/plugins/alerting/server/task_runner/types.ts @@ -8,8 +8,8 @@ import { Dictionary } from 'lodash'; import { KibanaRequest, Logger } from '@kbn/core/server'; import { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server'; -import { IEventLogger } from '@kbn/event-log-plugin/server'; import { PluginStartContract as ActionsPluginStartContract } from '@kbn/actions-plugin/server'; +import { PublicMethodsOf } from '@kbn/utility-types'; import { ActionGroup, RuleAction, @@ -20,7 +20,6 @@ import { IntervalSchedule, RuleMonitoring, RuleTaskState, - SanitizedRule, } from '../../common'; import { Alert } from '../alert'; import { NormalizedRuleType } from '../rule_type_registry'; @@ -28,6 +27,7 @@ import { ExecutionHandler } from './create_execution_handler'; import { RawRule } from '../types'; import { ActionsConfigMap } from '../lib/get_actions_config_map'; import { RuleRunMetrics, RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; +import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; export interface RuleTaskRunResult { state: RuleTaskState; @@ -61,29 +61,11 @@ export interface GenerateNewAndRecoveredAlertEventsParams< InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext > { - eventLogger: IEventLogger; - executionId: string; + alertingEventLogger: AlertingEventLogger; originalAlerts: Dictionary>; currentAlerts: Dictionary>; recoveredAlerts: Dictionary>; - ruleId: string; ruleLabel: string; - namespace: string | undefined; - ruleType: NormalizedRuleType< - RuleTypeParams, - RuleTypeParams, - RuleTypeState, - { - [x: string]: unknown; - }, - { - [x: string]: unknown; - }, - string, - string - >; - rule: SanitizedRule; - spaceId: string; ruleRunMetricsStore: RuleRunMetricsStore; } @@ -145,7 +127,7 @@ export interface CreateExecutionHandlerOptions< RecoveryActionGroupId >; logger: Logger; - eventLogger: IEventLogger; + alertingEventLogger: PublicMethodsOf; request: KibanaRequest; ruleParams: RuleTypeParams; supportsEphemeralTasks: boolean; diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 4a2290d0bde33c7..1c453df386e24c0 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -43,7 +43,7 @@ import { } from '../common'; export type WithoutQueryAndParams = Pick>; export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined; - +export type { RuleTypeParams }; /** * @public */ @@ -123,6 +123,7 @@ export type ExecutorType< export interface RuleTypeParamsValidator { validate: (object: unknown) => Params; + validateMutatedParams?: (mutatedOject: unknown, origObject?: unknown) => Params; } export interface RuleType< diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/agent_instructions_accordion.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/agent_instructions_accordion.tsx index 238ffc760d93f6f..a9ec9778ed3e63f 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/agent_instructions_accordion.tsx +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/agent_instructions_accordion.tsx @@ -13,7 +13,6 @@ import { EuiText, EuiCodeBlock, EuiTabbedContent, - EuiBetaBadge, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { ComponentType } from 'react'; @@ -32,6 +31,7 @@ import type { } from '../apm_policy_form/typings'; import { getCommands } from '../../../tutorial/config_agent/commands/get_commands'; import { renderMustache } from './render_mustache'; +import { TechnicalPreviewBadge } from '../../shared/technical_preview_badge'; function AccordionButtonContent({ agentName, @@ -240,19 +240,7 @@ export function AgentInstructionsAccordion({ )} - + ), diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/index.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/index.tsx index 8f2a1d3d1dea1d1..36e30dbd36f7235 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/index.tsx +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/index.tsx @@ -5,13 +5,8 @@ * 2.0. */ -import { - htmlIdGenerator, - euiDragDropReorder, - DropResult, - EuiComboBoxOptionOption, -} from '@elastic/eui'; -import React, { useState, useCallback, ReactNode } from 'react'; +import { htmlIdGenerator, euiDragDropReorder, DropResult } from '@elastic/eui'; +import React, { useState, useCallback, ReactNode, useEffect } from 'react'; import { RuntimeAttachment as RuntimeAttachmentStateless } from './runtime_attachment'; export const STAGED_DISCOVERY_RULE_ID = 'STAGED_DISCOVERY_RULE_ID'; @@ -42,8 +37,8 @@ interface Props { initialIsEnabled?: boolean; initialDiscoveryRules?: IDiscoveryRule[]; operationTypes: Operation[]; - selectedVersion: string; - versions: string[]; + version: RuntimeAttachmentSettings['version']; + invalidatePackagePolicy: () => void; } interface Option { @@ -58,8 +53,11 @@ export interface Operation { } const versionRegex = new RegExp(/^\d+\.\d+\.\d+$/); -function validateVersion(version: string) { - return versionRegex.test(version); +export function validateVersion(version: RuntimeAttachmentSettings['version']) { + if (version) { + return versionRegex.test(version); + } + return false; } export function RuntimeAttachment(props: Props) { @@ -75,10 +73,24 @@ export function RuntimeAttachment(props: Props) { const [editDiscoveryRuleId, setEditDiscoveryRuleId] = useState( null ); - const [version, setVersion] = useState(props.selectedVersion); - const [versions, setVersions] = useState(props.versions); + const [version, setVersion] = useState( + props.version + ); const [isValidVersion, setIsValidVersion] = useState( - validateVersion(version) + validateVersion(props.version) + ); + + useEffect( + () => { + // Invalidates the package policy, so save button is disabled + // until a valid version is provided + if (isEnabled && !isValidVersion) { + props.invalidatePackagePolicy(); + } + }, + // props shouldn't be listed as dependency here + // eslint-disable-next-line react-hooks/exhaustive-deps + [isEnabled, isValidVersion] ); const onToggleEnable = useCallback(() => { @@ -250,11 +262,14 @@ export function RuntimeAttachment(props: Props) { [isEnabled, discoveryRuleList, onChange, version] ); - function onChangeVersion(nextVersion?: string) { - if (!nextVersion) { + function onChangeVersion(nextVersion: RuntimeAttachmentSettings['version']) { + const isNextVersionValid = validateVersion(nextVersion); + setIsValidVersion(isNextVersionValid); + setVersion(nextVersion); + + if (!isNextVersionValid) { return; } - setVersion(nextVersion); onChange({ enabled: isEnabled, discoveryRules: isEnabled @@ -264,29 +279,6 @@ export function RuntimeAttachment(props: Props) { }); } - function onCreateNewVersion( - newVersion: string, - flattenedOptions: Array> - ) { - const normalizedNewVersion = newVersion.trim().toLowerCase(); - const isNextVersionValid = validateVersion(normalizedNewVersion); - setIsValidVersion(isNextVersionValid); - if (!normalizedNewVersion || !isNextVersionValid) { - return; - } - - // Create the option if it doesn't exist. - if ( - flattenedOptions.findIndex( - (option) => option.label.trim().toLowerCase() === normalizedNewVersion - ) === -1 - ) { - setVersions([...versions, newVersion]); - } - - onChangeVersion(newVersion); - } - return ( { - const nextVersion: string | undefined = selectedVersions[0]?.label; - const isNextVersionValid = validateVersion(nextVersion); - setIsValidVersion(isNextVersionValid); - onChangeVersion(nextVersion); - }} - onCreateNewVersion={onCreateNewVersion} + version={version} + onChangeVersion={onChangeVersion} isValidVersion={isValidVersion} /> ); diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/runtime_attachment.stories.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/runtime_attachment.stories.tsx index 12f6705284ff99f..3abfe44f9649818 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/runtime_attachment.stories.tsx +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/runtime_attachment.stories.tsx @@ -32,12 +32,11 @@ const excludeOptions = [ ]; const includeOptions = [{ value: 'all', label: 'All' }, ...excludeOptions]; -const versions = ['1.27.1', '1.27.0', '1.26.0', '1.25.0']; - export const RuntimeAttachmentExample: Story = () => { const [runtimeAttachmentSettings, setRuntimeAttachmentSettings] = useState( {} ); + const [isEnabled, setIsEnabled] = useState(true); return ( <> { toggleDescription="Attach the Java agent to running and starting Java applications." discoveryRulesDescription="For every running JVM, the discovery rules are evaluated in the order they are provided. The first matching rule determines the outcome. Learn more in the docs" showUnsavedWarning={true} - initialIsEnabled={true} + initialIsEnabled={isEnabled} initialDiscoveryRules={[ { operation: 'include', @@ -70,8 +69,10 @@ export const RuntimeAttachmentExample: Story = () => { probe: '10948653898867', }, ]} - versions={versions} - selectedVersion={versions[0]} + version={null} + invalidatePackagePolicy={() => { + setIsEnabled(false); + }} />
    {JSON.stringify(runtimeAttachmentSettings, null, 4)}
    diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/runtime_attachment.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/runtime_attachment.tsx index 3592eb4f0474528..1934a609f59bc7c 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/runtime_attachment.tsx +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/runtime_attachment.tsx @@ -18,16 +18,19 @@ import { EuiDraggable, EuiIcon, DropResult, - EuiComboBox, - EuiComboBoxProps, EuiFormRow, + EuiFieldText, + EuiLink, } from '@elastic/eui'; import React, { ReactNode } from 'react'; import { i18n } from '@kbn/i18n'; +import { isEmpty } from 'lodash'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; import { DiscoveryRule } from './discovery_rule'; import { DefaultDiscoveryRule } from './default_discovery_rule'; import { EditDiscoveryRule } from './edit_discovery_rule'; -import { IDiscoveryRuleList, Operation } from '.'; +import { IDiscoveryRuleList, Operation, RuntimeAttachmentSettings } from '.'; interface Props { isEnabled: boolean; @@ -51,10 +54,8 @@ interface Props { discoveryRulesDescription: ReactNode; showUnsavedWarning?: boolean; onDragEnd: (dropResult: DropResult) => void; - selectedVersion: string; - versions: string[]; - onChangeVersion: EuiComboBoxProps['onChange']; - onCreateNewVersion: EuiComboBoxProps['onCreateOption']; + version: RuntimeAttachmentSettings['version']; + onChangeVersion: (nextVersion: RuntimeAttachmentSettings['version']) => void; isValidVersion: boolean; } @@ -80,12 +81,13 @@ export function RuntimeAttachment({ discoveryRulesDescription, showUnsavedWarning, onDragEnd, - selectedVersion, - versions, + version, onChangeVersion, - onCreateNewVersion, isValidVersion, }: Props) { + const { + services: { docLinks }, + } = useKibana(); return (
    {showUnsavedWarning && ( @@ -120,7 +122,7 @@ export function RuntimeAttachment({

    {toggleDescription}

    - {isEnabled && versions && ( + {isEnabled && ( + {i18n.translate( + 'xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.version.helpText.version', + { defaultMessage: 'version' } + )} + + ), + }} + /> + } > - { + const nextVersion = e.target.value; + onChangeVersion(isEmpty(nextVersion) ? null : nextVersion); + }} placeholder={i18n.translate( 'xpack.apm.fleetIntegration.apmAgent.runtimeAttachment.version.placeHolder', - { defaultMessage: 'Select or add a version' } + { defaultMessage: 'Add a version' } )} - options={versions.map((_version) => ({ label: _version }))} - onChange={onChangeVersion} - onCreateOption={onCreateNewVersion} - singleSelection - isClearable={false} /> diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/supported_agents/java_runtime_attachment.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/supported_agents/java_runtime_attachment.tsx index 2284315d4a6bab5..8d047a2bcb9ecf1 100644 --- a/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/supported_agents/java_runtime_attachment.tsx +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_agents/runtime_attachment/supported_agents/java_runtime_attachment.tsx @@ -13,6 +13,7 @@ import { RuntimeAttachment, RuntimeAttachmentSettings, IDiscoveryRule, + validateVersion, } from '..'; import type { NewPackagePolicy, @@ -85,39 +86,6 @@ const includeOptions = [ ...excludeOptions, ]; -const versions = [ - '1.27.1', - '1.27.0', - '1.26.0', - '1.25.0', - '1.24.0', - '1.23.0', - '1.22.0', - '1.21.0', - '1.20.0', - '1.19.0', - '1.18.1', - '1.18.0', - '1.18.0.RC1', - '1.17.0', - '1.16.0', - '1.15.0', - '1.14.0', - '1.13.0', - '1.12.0', - '1.11.0', - '1.10.0', - '1.9.0', - '1.8.0', - '1.7.0', - '1.6.1', - '1.6.0', - '1.5.0', - '1.4.0', - '1.3.0', - '1.2.0', -]; - function getApmVars(newPolicy: NewPackagePolicy) { return newPolicy.inputs.find(({ type }) => type === 'apm')?.vars; } @@ -130,7 +98,7 @@ export function JavaRuntimeAttachment({ newPolicy, onChange }: Props) { ({ type }) => type === 'apm' ); onChange({ - isValid: true, + isValid: validateVersion(runtimeAttachmentSettings.version), updatedPolicy: { ...newPolicy, inputs: [ @@ -164,6 +132,10 @@ export function JavaRuntimeAttachment({ newPolicy, onChange }: Props) { [newPolicy, onChange] ); + function invalidatePackagePolicy() { + onChange({ isValid: false, updatedPolicy: newPolicy }); + } + const apmVars = useMemo(() => getApmVars(newPolicy), [newPolicy]); return ( @@ -223,10 +195,8 @@ export function JavaRuntimeAttachment({ newPolicy, onChange }: Props) { apmVars?.java_attacher_discovery_rules?.value ?? '[]\n', [initialDiscoveryRule] )} - selectedVersion={ - apmVars?.java_attacher_agent_version?.value || versions[0] - } - versions={versions} + version={apmVars?.java_attacher_agent_version?.value || null} + invalidatePackagePolicy={invalidatePackagePolicy} /> ); } diff --git a/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/edit_apm_policy_form.stories.tsx b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/edit_apm_policy_form.stories.tsx new file mode 100644 index 000000000000000..e4e6f7062ffdb36 --- /dev/null +++ b/x-pack/plugins/apm/public/components/fleet_integration/apm_policy_form/edit_apm_policy_form.stories.tsx @@ -0,0 +1,272 @@ +/* + * 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 React, { useState } from 'react'; +import { Meta, Story } from '@storybook/react'; +import { CoreStart } from '@kbn/core/public'; +import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public'; +import { EditAPMPolicyForm } from './edit_apm_policy_form'; +import { NewPackagePolicy, PackagePolicy } from './typings'; + +const coreMock = { + http: { get: async () => ({}) }, + notifications: { toasts: { add: () => {} } }, + uiSettings: { get: () => {} }, +} as unknown as CoreStart; + +const KibanaReactContext = createKibanaReactContext(coreMock); + +const stories: Meta<{}> = { + title: 'fleet/Edit APM policy', + component: EditAPMPolicyForm, + decorators: [ + (StoryComponent) => { + return ( +
    + + + +
    + ); + }, + ], +}; +export default stories; + +export const EditAPMPolicy: Story = () => { + const [newPolicy, setNewPolicy] = useState(policy); + const [isPolicyValid, setIsPolicyValid] = useState(true); + + return ( + <> +
    +
    +          {`Is Policy valid: ${isPolicyValid} (when false, "Save integration" button is disabled)`}
    +        
    +
    + { + setIsPolicyValid(value.isValid); + const updatedVars = value.updatedPolicy.inputs?.[0].vars; + setNewPolicy((state) => ({ + ...state, + inputs: [{ ...state.inputs[0], vars: updatedVars }], + })); + }} + /> +
    +
    +
    {JSON.stringify(newPolicy, null, 4)}
    + + ); +}; + +const policy = { + version: 'WzM2OTksMl0=', + name: 'Elastic APM', + namespace: 'default', + enabled: true, + policy_id: 'policy-elastic-agent-on-cloud', + output_id: '', + package: { + name: 'apm', + version: '8.3.0', + title: 'Elastic APM', + }, + elasticsearch: { + privileges: { + cluster: ['cluster:monitor/main'], + }, + }, + inputs: [ + { + type: 'apm', + enabled: true, + config: { + 'apm-server': { + value: { + rum: { + source_mapping: { + metadata: [], + }, + }, + agent_config: [], + }, + }, + }, + streams: [], + vars: { + host: { + type: 'text', + value: '0.0.0.0:8200', + }, + url: { + type: 'text', + value: 'cloud_apm_url_test', + }, + secret_token: { + type: 'text', + value: 'asdfkjhasdf', + }, + api_key_enabled: { + type: 'bool', + value: true, + }, + enable_rum: { + type: 'bool', + value: true, + }, + anonymous_enabled: { + type: 'bool', + value: true, + }, + anonymous_allow_agent: { + type: 'text', + value: ['rum-js', 'js-base', 'iOS/swift'], + }, + anonymous_allow_service: { + type: 'text', + value: '', + }, + anonymous_rate_limit_event_limit: { + type: 'integer', + value: 300, + }, + anonymous_rate_limit_ip_limit: { + type: 'integer', + value: 1000, + }, + default_service_environment: { + type: 'text', + value: '', + }, + rum_allow_origins: { + type: 'text', + value: ['"*"'], + }, + rum_allow_headers: { + type: 'text', + value: '', + }, + rum_response_headers: { + type: 'yaml', + value: '', + }, + rum_library_pattern: { + type: 'text', + value: '"node_modules|bower_components|~"', + }, + rum_exclude_from_grouping: { + type: 'text', + value: '"^/webpack"', + }, + api_key_limit: { + type: 'integer', + value: 100, + }, + max_event_bytes: { + type: 'integer', + value: 307200, + }, + capture_personal_data: { + type: 'bool', + value: true, + }, + max_header_bytes: { + type: 'integer', + value: 1048576, + }, + idle_timeout: { + type: 'text', + value: '45s', + }, + read_timeout: { + type: 'text', + value: '3600s', + }, + shutdown_timeout: { + type: 'text', + value: '30s', + }, + write_timeout: { + type: 'text', + value: '30s', + }, + max_connections: { + type: 'integer', + value: 0, + }, + response_headers: { + type: 'yaml', + value: '', + }, + expvar_enabled: { + type: 'bool', + value: false, + }, + java_attacher_discovery_rules: { + type: 'yaml', + value: '', + }, + java_attacher_agent_version: { + type: 'text', + value: '', + }, + java_attacher_enabled: { + type: 'bool', + value: false, + }, + tls_enabled: { + type: 'bool', + value: false, + }, + tls_certificate: { + type: 'text', + value: '', + }, + tls_key: { + type: 'text', + value: '', + }, + tls_supported_protocols: { + type: 'text', + value: ['TLSv1.0', 'TLSv1.1', 'TLSv1.2'], + }, + tls_cipher_suites: { + type: 'text', + value: '', + }, + tls_curve_types: { + type: 'text', + value: '', + }, + tail_sampling_policies: { + type: 'yaml', + value: '- sample_rate: 0.1\n', + }, + tail_sampling_interval: { + type: 'text', + value: '1m', + }, + tail_sampling_enabled: { + type: 'bool', + value: false, + }, + }, + }, + ], +} as NewPackagePolicy; diff --git a/x-pack/plugins/apm/public/components/shared/stacktrace/cause_stacktrace.tsx b/x-pack/plugins/apm/public/components/shared/stacktrace/cause_stacktrace.tsx index afa9fd640472d44..b132239902f9da7 100644 --- a/x-pack/plugins/apm/public/components/shared/stacktrace/cause_stacktrace.tsx +++ b/x-pack/plugins/apm/public/components/shared/stacktrace/cause_stacktrace.tsx @@ -18,7 +18,7 @@ const Accordion = euiStyled(EuiAccordion)` `; const CausedByContainer = euiStyled('h5')` - padding: ${({ theme }) => theme.eui.spacerSizes.s} 0; + padding: ${({ theme }) => theme.eui.euiSizeS} 0; `; const CausedByHeading = euiStyled('span')` diff --git a/x-pack/plugins/apm/public/components/shared/technical_preview_badge.tsx b/x-pack/plugins/apm/public/components/shared/technical_preview_badge.tsx new file mode 100644 index 000000000000000..7068c9c6fe7933d --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/technical_preview_badge.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiBetaBadge } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +export function TechnicalPreviewBadge() { + return ( + + ); +} diff --git a/x-pack/plugins/apm/public/tutorial/config_agent/commands/django.ts b/x-pack/plugins/apm/public/tutorial/config_agent/commands/django.ts index 18fed9d329cd04a..ac1b96e75353595 100644 --- a/x-pack/plugins/apm/public/tutorial/config_agent/commands/django.ts +++ b/x-pack/plugins/apm/public/tutorial/config_agent/commands/django.ts @@ -31,7 +31,7 @@ ELASTIC_APM = { defaultMessage: 'a-z, A-Z, 0-9, -, _, and space', } )} -'SERVICE_NAME': '', +'SERVICE_NAME': 'my_python_service', # ${i18n.translate( 'xpack.apm.tutorial.djangoClient.configure.commands.useIfApmServerRequiresTokenComment', diff --git a/x-pack/plugins/apm/public/tutorial/config_agent/commands/flask.ts b/x-pack/plugins/apm/public/tutorial/config_agent/commands/flask.ts index dbcd6f29225c1a8..305e9390356a3cb 100644 --- a/x-pack/plugins/apm/public/tutorial/config_agent/commands/flask.ts +++ b/x-pack/plugins/apm/public/tutorial/config_agent/commands/flask.ts @@ -38,7 +38,7 @@ app.config['ELASTIC_APM'] = { defaultMessage: 'a-z, A-Z, 0-9, -, _, and space', } )} -'SERVICE_NAME': '', +'SERVICE_NAME': 'my_python_service', # ${i18n.translate( 'xpack.apm.tutorial.flaskClient.configure.commands.useIfApmServerRequiresTokenComment', diff --git a/x-pack/plugins/apm/public/tutorial/config_agent/commands/get_commands.test.ts b/x-pack/plugins/apm/public/tutorial/config_agent/commands/get_commands.test.ts index bb6593ae7acb83c..4c7b311d935d0ff 100644 --- a/x-pack/plugins/apm/public/tutorial/config_agent/commands/get_commands.test.ts +++ b/x-pack/plugins/apm/public/tutorial/config_agent/commands/get_commands.test.ts @@ -181,7 +181,7 @@ describe('getCommands', () => { ELASTIC_APM = { # Set the required service name. Allowed characters: # a-z, A-Z, 0-9, -, _, and space - 'SERVICE_NAME': '', + 'SERVICE_NAME': 'my_python_service', # Use if APM Server requires a secret token 'SECRET_TOKEN': '', @@ -219,7 +219,7 @@ describe('getCommands', () => { ELASTIC_APM = { # Set the required service name. Allowed characters: # a-z, A-Z, 0-9, -, _, and space - 'SERVICE_NAME': '', + 'SERVICE_NAME': 'my_python_service', # Use if APM Server requires a secret token 'SECRET_TOKEN': 'foobar', @@ -257,7 +257,7 @@ describe('getCommands', () => { app.config['ELASTIC_APM'] = { # Set the required service name. Allowed characters: # a-z, A-Z, 0-9, -, _, and space - 'SERVICE_NAME': '', + 'SERVICE_NAME': 'my_python_service', # Use if APM Server requires a secret token 'SECRET_TOKEN': '', @@ -292,7 +292,7 @@ describe('getCommands', () => { app.config['ELASTIC_APM'] = { # Set the required service name. Allowed characters: # a-z, A-Z, 0-9, -, _, and space - 'SERVICE_NAME': '', + 'SERVICE_NAME': 'my_python_service', # Use if APM Server requires a secret token 'SECRET_TOKEN': 'foobar', @@ -511,7 +511,7 @@ describe('getCommands', () => { expect(commands).not.toBe(''); expect(commands).toMatchInlineSnapshot(` "elastic_apm.server_url=\\"\\" - elastic.apm.secret_token=\\"\\" + elastic_apm.secret_token=\\"\\" elastic_apm.service_name=\\"My service\\" " `); @@ -527,7 +527,7 @@ describe('getCommands', () => { expect(commands).not.toBe(''); expect(commands).toMatchInlineSnapshot(` "elastic_apm.server_url=\\"localhost:8220\\" - elastic.apm.secret_token=\\"foobar\\" + elastic_apm.secret_token=\\"foobar\\" elastic_apm.service_name=\\"My service\\" " `); diff --git a/x-pack/plugins/apm/public/tutorial/config_agent/commands/php.ts b/x-pack/plugins/apm/public/tutorial/config_agent/commands/php.ts index ea7e8764f89ad1a..dba4147b8afbc29 100644 --- a/x-pack/plugins/apm/public/tutorial/config_agent/commands/php.ts +++ b/x-pack/plugins/apm/public/tutorial/config_agent/commands/php.ts @@ -6,6 +6,6 @@ */ export const php = `elastic_apm.server_url="{{{apmServerUrl}}}" -elastic.apm.secret_token="{{{secretToken}}}" +elastic_apm.secret_token="{{{secretToken}}}" elastic_apm.service_name="My service" `; diff --git a/x-pack/plugins/apm/scripts/create_apm_users_and_roles/helpers/call_kibana.ts b/x-pack/plugins/apm/scripts/create_apm_users_and_roles/helpers/call_kibana.ts index e87c6ac7c854472..00ca3506a42814d 100644 --- a/x-pack/plugins/apm/scripts/create_apm_users_and_roles/helpers/call_kibana.ts +++ b/x-pack/plugins/apm/scripts/create_apm_users_and_roles/helpers/call_kibana.ts @@ -34,7 +34,7 @@ const getBaseUrl = once(async (kibanaHostname: string) => { await axios.request({ url: kibanaHostname, maxRedirects: 0 }); } catch (e) { if (isAxiosError(e)) { - const location = e.response?.headers?.location; + const location = e.response?.headers?.location ?? ''; const hasBasePath = RegExp(/^\/\w{3}$/).test(location); const basePath = hasBasePath ? location : ''; return `${kibanaHostname}${basePath}`; diff --git a/x-pack/plugins/apm/server/lib/helpers/get_metric_indices.ts b/x-pack/plugins/apm/server/lib/helpers/get_metric_indices.ts new file mode 100644 index 000000000000000..61d12ba73094211 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/helpers/get_metric_indices.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsClientContract } from '@kbn/core/server'; +import { APMRouteHandlerResources } from '../../routes/typings'; + +export async function getMetricIndices({ + infraPlugin, + savedObjectsClient, +}: { + infraPlugin: Required; + savedObjectsClient: SavedObjectsClientContract; +}): Promise { + const infra = await infraPlugin.start(); + const metricIndices = await infra.getMetricIndices(savedObjectsClient); + + return metricIndices; +} diff --git a/x-pack/plugins/apm/server/types.ts b/x-pack/plugins/apm/server/types.ts index d37588672874898..73258ef2008fae2 100644 --- a/x-pack/plugins/apm/server/types.ts +++ b/x-pack/plugins/apm/server/types.ts @@ -49,6 +49,7 @@ import { FleetSetupContract as FleetPluginSetup, FleetStartContract as FleetPluginStart, } from '@kbn/fleet-plugin/server'; +import { InfraPluginStart, InfraPluginSetup } from '@kbn/infra-plugin/server'; import { APMConfig } from '.'; import { ApmIndicesConfig } from './routes/settings/apm_indices/get_apm_indices'; import { APMEventClient } from './lib/helpers/create_es_client/create_apm_event_client'; @@ -71,6 +72,7 @@ export interface APMPluginSetupDependencies { licensing: LicensingPluginSetup; observability: ObservabilityPluginSetup; ruleRegistry: RuleRegistryPluginSetupContract; + infra: InfraPluginSetup; // optional dependencies actions?: ActionsPlugin['setup']; @@ -92,6 +94,7 @@ export interface APMPluginStartDependencies { licensing: LicensingPluginStart; observability: undefined; ruleRegistry: RuleRegistryPluginStartContract; + infra: InfraPluginStart; // optional dependencies actions?: ActionsPlugin['start']; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/esdocs.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/esdocs.ts index f4349af6e785e2d..ac946ea885ed0ed 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/esdocs.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/browser/esdocs.ts @@ -11,9 +11,6 @@ import { } from '@kbn/expressions-plugin/common'; import { lastValueFrom } from 'rxjs'; -// @ts-expect-error untyped local -import { buildESRequest } from '../../../common/lib/request/build_es_request'; - import { searchService } from '../../../public/services'; import { ESSQL_SEARCH_STRATEGY } from '../../../common/lib/constants'; import { EssqlSearchStrategyRequest, EssqlSearchStrategyResponse } from '../../../types'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/embeddable.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/embeddable.ts index 2554d2efd1bc9ce..a74b4c262dd7961 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/external/embeddable.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/functions/external/embeddable.ts @@ -163,10 +163,12 @@ export function embeddableFunctionFactory({ return state; }, - migrations: mapValues< - MigrateFunctionsObject, - MigrateFunction - >(embeddablePersistableStateService.getAllMigrations(), migrateByValueEmbeddable), + migrations: () => { + return mapValues< + MigrateFunctionsObject, + MigrateFunction + >(embeddablePersistableStateService.getAllMigrations(), migrateByValueEmbeddable); + }, }; }; } diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__stories__/__snapshots__/extended_template.stories.storyshot b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__stories__/__snapshots__/extended_template.stories.storyshot index beab512ea62e1e9..49e87d067e63a40 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__stories__/__snapshots__/extended_template.stories.storyshot +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__stories__/__snapshots__/extended_template.stories.storyshot @@ -75,7 +75,7 @@ exports[`Storyshots arguments/AxisConfig extended 1`] = `
    `; @@ -155,7 +155,7 @@ exports[`Storyshots arguments/AxisConfig/components extended 1`] = `
    `; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/date_format/__stories__/__snapshots__/date_format.stories.storyshot b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/date_format/__stories__/__snapshots__/date_format.stories.storyshot index 3cb7e726a9389b1..a868d023ec13534 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/date_format/__stories__/__snapshots__/date_format.stories.storyshot +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/date_format/__stories__/__snapshots__/date_format.stories.storyshot @@ -55,7 +55,7 @@ Array [
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    { + // WORKAROUND: wrong Axios types, should be fixed in https://github.com/axios/axios/pull/4475 + const getDefaultHeader = (axiosInstance: AxiosInstance, headerName: string) => + (axiosInstance.defaults.headers as HeadersDefaults & Record)[headerName]; + it('test fetch headers', () => { - expect(fetch.defaults.headers.Accept).toBe('application/json'); - expect(fetch.defaults.headers['Content-Type']).toBe('application/json'); - expect(fetch.defaults.headers['kbn-xsrf']).toBe('professionally-crafted-string-of-text'); + expect(getDefaultHeader(fetch, 'Accept')).toBe('application/json'); + expect(getDefaultHeader(fetch, 'Content-Type')).toBe('application/json'); + expect(getDefaultHeader(fetch, 'kbn-xsrf')).toBe('professionally-crafted-string-of-text'); }); it('test arrayBufferFetch headers', () => { - expect(arrayBufferFetch.defaults.headers.Accept).toBe('application/json'); - expect(arrayBufferFetch.defaults.headers['Content-Type']).toBe('application/json'); - expect(arrayBufferFetch.defaults.headers['kbn-xsrf']).toBe( + expect(getDefaultHeader(arrayBufferFetch, 'Accept')).toBe('application/json'); + expect(getDefaultHeader(arrayBufferFetch, 'Content-Type')).toBe('application/json'); + expect(getDefaultHeader(arrayBufferFetch, 'kbn-xsrf')).toBe( 'professionally-crafted-string-of-text' ); expect(arrayBufferFetch.defaults.responseType).toBe('arraybuffer'); diff --git a/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset.stories.storyshot b/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset.stories.storyshot index 587b07ca4f93284..ce45f123172dc60 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/asset_manager/__stories__/__snapshots__/asset.stories.storyshot @@ -29,7 +29,7 @@ exports[`Storyshots components/Assets/Asset airplane 1`] = `





    diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/__stories__/__snapshots__/saved_elements_modal.stories.storyshot b/x-pack/plugins/canvas/public/components/saved_elements_modal/__stories__/__snapshots__/saved_elements_modal.stories.storyshot index d7dc9a062e3eed2..4b53b885aa7a885 100644 --- a/x-pack/plugins/canvas/public/components/saved_elements_modal/__stories__/__snapshots__/saved_elements_modal.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/saved_elements_modal/__stories__/__snapshots__/saved_elements_modal.stories.storyshot @@ -88,7 +88,7 @@ exports[`Storyshots components/SavedElementsModal no custom elements 1`] = `
    { return ; diff --git a/x-pack/plugins/canvas/public/components/text_style_picker/__stories__/__snapshots__/text_style_picker.stories.storyshot b/x-pack/plugins/canvas/public/components/text_style_picker/__stories__/__snapshots__/text_style_picker.stories.storyshot index 31013aaba10e6c5..38551d8a45bf34e 100644 --- a/x-pack/plugins/canvas/public/components/text_style_picker/__stories__/__snapshots__/text_style_picker.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/text_style_picker/__stories__/__snapshots__/text_style_picker.stories.storyshot @@ -162,7 +162,7 @@ exports[`Storyshots components/TextStylePicker default 1`] = `
    customElementMigrationsFactory(deps), management: { icon: 'canvasApp', defaultSearchField: 'name', diff --git a/x-pack/plugins/canvas/server/saved_objects/workpad.ts b/x-pack/plugins/canvas/server/saved_objects/workpad.ts index b23ea62f889541e..6c392aaaa62b1a1 100644 --- a/x-pack/plugins/canvas/server/saved_objects/workpad.ts +++ b/x-pack/plugins/canvas/server/saved_objects/workpad.ts @@ -32,7 +32,7 @@ export const workpadTypeFactory = ( '@created': { type: 'date' }, }, }, - migrations: workpadMigrationsFactory(deps), + migrations: () => workpadMigrationsFactory(deps), management: { importableAndExportable: true, icon: 'canvasApp', diff --git a/x-pack/plugins/canvas/server/saved_objects/workpad_template.ts b/x-pack/plugins/canvas/server/saved_objects/workpad_template.ts index ec852113e1ca4cd..224af9bed675902 100644 --- a/x-pack/plugins/canvas/server/saved_objects/workpad_template.ts +++ b/x-pack/plugins/canvas/server/saved_objects/workpad_template.ts @@ -50,7 +50,7 @@ export const workpadTemplateType = ( }, }, }, - migrations: templateWorkpadMigrationsFactory(deps), + migrations: () => templateWorkpadMigrationsFactory(deps), management: { importableAndExportable: false, icon: 'canvasApp', diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__snapshots__/settings.test.tsx.snap b/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__snapshots__/settings.test.tsx.snap index c12891f376e6fd1..7693388acd319af 100644 --- a/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__snapshots__/settings.test.tsx.snap +++ b/x-pack/plugins/canvas/shareable_runtime/components/footer/settings/__snapshots__/settings.test.tsx.snap @@ -256,7 +256,7 @@ exports[` can navigate Autoplay Settings 2`] = `




    ; export interface UpdateByKey { diff --git a/x-pack/plugins/cases/docs/openapi/README.md b/x-pack/plugins/cases/docs/openapi/README.md new file mode 100644 index 000000000000000..1ff3e24c2e91f9a --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/README.md @@ -0,0 +1,29 @@ +# OpenAPI (Experimental) + +The current self-contained spec file is [as JSON](https://github.com/raw/elastic/kibana/master/x-pack/plugins/cases/common/openapi/bundled.json) or [as YAML](https://github.com/raw/elastic/kibana/master/x-pack/plugins/cases/common/openapi/bundled.yaml) and can be used for online tools like those found at https://openapi.tools/. +This spec is experimental and may be incomplete or change later. + +A guide about the openApi specification can be found at [https://swagger.io/docs/specification/about/](https://swagger.io/docs/specification/about/). + +## The `openapi` folder + +* `entrypoint.yaml` is the overview file which pulls together all the paths and components. +* [Paths](paths/README.md): this defines each endpoint. A path can have one operation per http method. +* [Components](components/README.md): Reusable components + +## Tools + +It is possible to validate the docs before bundling them with the following +command in the `x-pack/plugins/cases/docs/openapi/` folder: + + ``` + npx swagger-cli validate entrypoint.yaml + ``` + +Then you can generate the `bundled` files by running the following commands: + + ``` + npx @redocly/openapi-cli bundle --ext yaml --output bundled.yaml entrypoint.yaml + npx @redocly/openapi-cli bundle --ext json --output bundled.json entrypoint.yaml + ``` + diff --git a/x-pack/plugins/cases/docs/openapi/bundled.json b/x-pack/plugins/cases/docs/openapi/bundled.json new file mode 100644 index 000000000000000..0cb084b5beb7c92 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/bundled.json @@ -0,0 +1,2144 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Cases", + "description": "OpenAPI schema for Cases endpoints", + "version": "0.1", + "contact": { + "name": "Cases Team" + }, + "license": { + "name": "Elastic License 2.0", + "url": "https://www.elastic.co/licensing/elastic-license" + } + }, + "tags": [ + { + "name": "cases", + "description": "Case APIs enable you to open and track issues." + }, + { + "name": "kibana", + "description": "Kibana APIs enable you to interact with Kibana features." + } + ], + "servers": [ + { + "url": "http://localhost:5601", + "description": "local" + } + ], + "paths": { + "/api/cases": { + "post": { + "description": "Creates a case. You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're creating.\n", + "tags": [ + "cases", + "kibana" + ], + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connector": { + "description": "An object that contains the connector configuration.", + "type": "object", + "properties": { + "fields": { + "description": "An object containing the connector fields. To create a case without a connector, specify null. If you want to omit any individual field, specify null as its value.", + "nullable": true, + "type": "object", + "properties": { + "caseId": { + "description": "The case identifier for Swimlane connectors.", + "type": "string" + }, + "category": { + "description": "The category of the incident for ServiceNow ITSM and ServiceNow SecOps connectors.", + "type": "string" + }, + "destIp": { + "description": "A comma-separated list of destination IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "impact": { + "description": "The effect an incident had on business for ServiceNow ITSM connectors.", + "type": "string" + }, + "issueType": { + "description": "The type of issue for Jira connectors.", + "type": "string" + }, + "issueTypes": { + "description": "The type of incident for IBM Resilient connectors.", + "type": "array", + "items": { + "type": "number" + } + }, + "malwareHash": { + "description": "A comma-separated list of malware hashes for ServiceNow SecOps connectors.", + "type": "string" + }, + "malwareUrl": { + "description": "A comma-separated list of malware URLs for ServiceNow SecOps connectors.", + "type": "string" + }, + "parent": { + "description": "The key of the parent issue, when the issue type is sub-task for Jira connectors.", + "type": "string" + }, + "priority": { + "description": "The priority of the issue for Jira and ServiceNow SecOps connectors.", + "type": "string" + }, + "severity": { + "description": "The severity of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "severityCode": { + "description": "The severity code of the incident for IBM Resilient connectors.", + "type": "number" + }, + "sourceIp": { + "description": "A comma-separated list of source IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "subcategory": { + "description": "The subcategory of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "urgency": { + "description": "The extent to which the incident resolution can be delayed for ServiceNow ITSM connectors.", + "type": "string" + } + }, + "required": [ + "fields", + "id", + "name", + "type" + ] + }, + "id": { + "description": "The identifier for the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "name": { + "description": "The name of the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/connector_types" + } + } + }, + "description": { + "description": "The description for the case.", + "type": "string" + }, + "owner": { + "$ref": "#/components/schemas/owners" + }, + "settings": { + "description": "An object that contains the case settings.", + "type": "object", + "properties": { + "syncAlerts": { + "description": "Turns alert syncing on or off.", + "type": "boolean" + } + } + }, + "tags": { + "description": "The words and phrases that help categorize cases. It can be an empty array.", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "A title for the case.", + "type": "string" + } + }, + "required": [ + "connector", + "description", + "owner", + "settings", + "tags", + "title" + ] + }, + "examples": { + "createCaseRequest": { + "$ref": "#/components/examples/create_case_request" + } + } + } + } + }, + "responses": { + "200": { + "description": "Indicates a successful call.", + "content": { + "application/json; charset=utf-8": { + "schema": { + "type": "object", + "properties": { + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "example": null + }, + "closed_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + }, + "comments": { + "type": "array", + "items": { + "type": "string" + }, + "example": [] + }, + "connector": { + "type": "object", + "properties": { + "fields": { + "description": "An object containing the connector fields. To create a case without a connector, specify null. If you want to omit any individual field, specify null as its value.", + "nullable": true, + "type": "object", + "properties": { + "caseId": { + "description": "The case identifier for Swimlane connectors.", + "type": "string" + }, + "category": { + "description": "The category of the incident for ServiceNow ITSM and ServiceNow SecOps connectors.", + "type": "string" + }, + "destIp": { + "description": "A comma-separated list of destination IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "impact": { + "description": "The effect an incident had on business for ServiceNow ITSM connectors.", + "type": "string" + }, + "issueType": { + "description": "The type of issue for Jira connectors.", + "type": "string" + }, + "issueTypes": { + "description": "The type of incident for IBM Resilient connectors.", + "type": "array", + "items": { + "type": "number" + } + }, + "malwareHash": { + "description": "A comma-separated list of malware hashes for ServiceNow SecOps connectors.", + "type": "string" + }, + "malwareUrl": { + "description": "A comma-separated list of malware URLs for ServiceNow SecOps connectors.", + "type": "string" + }, + "parent": { + "description": "The key of the parent issue, when the issue type is sub-task for Jira connectors.", + "type": "string" + }, + "priority": { + "description": "The priority of the issue for Jira and ServiceNow SecOps connectors.", + "type": "string" + }, + "severity": { + "description": "The severity of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "severityCode": { + "description": "The severity code of the incident for IBM Resilient connectors.", + "type": "number" + }, + "sourceIp": { + "description": "A comma-separated list of source IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "subcategory": { + "description": "The subcategory of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "urgency": { + "description": "The extent to which the incident resolution can be delayed for ServiceNow ITSM connectors.", + "type": "string" + } + }, + "required": [ + "fields", + "id", + "name", + "type" + ] + }, + "id": { + "description": "The identifier for the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "name": { + "description": "The name of the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/connector_types" + } + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2022-05-13T09:16:17.416Z" + }, + "created_by": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "ahunley@imf.usa.gov" + }, + "full_name": { + "type": "string", + "example": "Alan Hunley" + }, + "username": { + "type": "string", + "example": "ahunley" + } + } + }, + "description": { + "type": "string", + "example": "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": { + "type": "integer", + "description": "The elapsed time from the creation of the case to its closure (in seconds). If the case has not been closed, the duration is set to null.", + "example": 120 + }, + "external_service": { + "type": "object", + "properties": { + "connector_id": { + "type": "string" + }, + "connector_name": { + "type": "string" + }, + "external_id": { + "type": "string" + }, + "external_title": { + "type": "string" + }, + "external_url": { + "type": "string" + }, + "pushed_at": { + "type": "string", + "format": "date-time" + }, + "pushed_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + } + } + }, + "id": { + "type": "string", + "example": "66b9aa00-94fa-11ea-9f74-e7e108796192" + }, + "owner": { + "$ref": "#/components/schemas/owners" + }, + "settings": { + "type": "object", + "properties": { + "syncAlerts": { + "type": "boolean", + "example": true + } + } + }, + "status": { + "$ref": "#/components/schemas/status" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "phishing", + "social engineering", + "bubblegum" + ] + }, + "title": { + "type": "string", + "example": "This case will self-destruct in 5 seconds" + }, + "totalAlerts": { + "type": "integer", + "example": 0 + }, + "totalComment": { + "type": "integer", + "example": 0 + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "example": null + }, + "updated_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + }, + "version": { + "type": "string", + "example": "WzUzMiwxXQ==" + } + } + }, + "examples": { + "createCaseResponse": { + "$ref": "#/components/examples/create_case_response" + } + } + } + } + } + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, + "delete": { + "description": "Deletes one or more cases. You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the cases you're deleting.\n", + "tags": [ + "cases", + "kibana" + ], + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + }, + { + "name": "ids", + "description": "The cases that you want to removed. To retrieve case IDs, use the find cases API. All non-ASCII characters must be URL encoded.", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "example": "d4e7abb0-b462-11ec-9a8d-698504725a43" + } + ], + "responses": { + "204": { + "description": "Indicates a successful call." + } + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, + "patch": { + "description": "Updates one or more cases. You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're updating.\n", + "tags": [ + "cases", + "kibana" + ], + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "connector": { + "description": "An object that contains the connector configuration.", + "type": "object", + "properties": { + "fields": { + "description": "An object containing the connector fields. To create a case without a connector, specify null. If you want to omit any individual field, specify null as its value.", + "nullable": true, + "type": "object", + "properties": { + "caseId": { + "description": "The case identifier for Swimlane connectors.", + "type": "string" + }, + "category": { + "description": "The category of the incident for ServiceNow ITSM and ServiceNow SecOps connectors.", + "type": "string" + }, + "destIp": { + "description": "A comma-separated list of destination IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "impact": { + "description": "The effect an incident had on business for ServiceNow ITSM connectors.", + "type": "string" + }, + "issueType": { + "description": "The type of issue for Jira connectors.", + "type": "string" + }, + "issueTypes": { + "description": "The type of incident for IBM Resilient connectors.", + "type": "array", + "items": { + "type": "number" + } + }, + "malwareHash": { + "description": "A comma-separated list of malware hashes for ServiceNow SecOps connectors.", + "type": "string" + }, + "malwareUrl": { + "description": "A comma-separated list of malware URLs for ServiceNow SecOps connectors.", + "type": "string" + }, + "parent": { + "description": "The key of the parent issue, when the issue type is sub-task for Jira connectors.", + "type": "string" + }, + "priority": { + "description": "The priority of the issue for Jira and ServiceNow SecOps connectors.", + "type": "string" + }, + "severity": { + "description": "The severity of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "severityCode": { + "description": "The severity code of the incident for IBM Resilient connectors.", + "type": "number" + }, + "sourceIp": { + "description": "A comma-separated list of source IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "subcategory": { + "description": "The subcategory of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "urgency": { + "description": "The extent to which the incident resolution can be delayed for ServiceNow ITSM connectors.", + "type": "string" + } + }, + "required": [ + "fields", + "id", + "name", + "type" + ] + }, + "id": { + "description": "The identifier for the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "name": { + "description": "The name of the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/connector_types" + } + } + }, + "description": { + "description": "The description for the case.", + "type": "string" + }, + "id": { + "description": "The identifier for the case.", + "type": "string" + }, + "settings": { + "description": "An object that contains the case settings.", + "type": "object", + "properties": { + "syncAlerts": { + "description": "Turns alert syncing on or off.", + "type": "boolean" + } + } + }, + "status": { + "$ref": "#/components/schemas/status" + }, + "tags": { + "description": "The words and phrases that help categorize cases.", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "A title for the case.", + "type": "string" + }, + "version": { + "description": "The current version of the case.", + "type": "string" + } + }, + "required": [ + "id", + "version" + ] + } + } + } + }, + "examples": { + "updateCaseRequest": { + "$ref": "#/components/examples/update_case_request" + } + } + } + } + }, + "responses": { + "200": { + "description": "Indicates a successful call.", + "content": { + "application/json; charset=utf-8": { + "schema": { + "type": "object", + "properties": { + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "example": null + }, + "closed_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + }, + "comments": { + "type": "array", + "items": { + "type": "string" + }, + "example": [] + }, + "connector": { + "type": "object", + "properties": { + "fields": { + "description": "An object containing the connector fields. To create a case without a connector, specify null. If you want to omit any individual field, specify null as its value.", + "nullable": true, + "type": "object", + "properties": { + "caseId": { + "description": "The case identifier for Swimlane connectors.", + "type": "string" + }, + "category": { + "description": "The category of the incident for ServiceNow ITSM and ServiceNow SecOps connectors.", + "type": "string" + }, + "destIp": { + "description": "A comma-separated list of destination IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "impact": { + "description": "The effect an incident had on business for ServiceNow ITSM connectors.", + "type": "string" + }, + "issueType": { + "description": "The type of issue for Jira connectors.", + "type": "string" + }, + "issueTypes": { + "description": "The type of incident for IBM Resilient connectors.", + "type": "array", + "items": { + "type": "number" + } + }, + "malwareHash": { + "description": "A comma-separated list of malware hashes for ServiceNow SecOps connectors.", + "type": "string" + }, + "malwareUrl": { + "description": "A comma-separated list of malware URLs for ServiceNow SecOps connectors.", + "type": "string" + }, + "parent": { + "description": "The key of the parent issue, when the issue type is sub-task for Jira connectors.", + "type": "string" + }, + "priority": { + "description": "The priority of the issue for Jira and ServiceNow SecOps connectors.", + "type": "string" + }, + "severity": { + "description": "The severity of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "severityCode": { + "description": "The severity code of the incident for IBM Resilient connectors.", + "type": "number" + }, + "sourceIp": { + "description": "A comma-separated list of source IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "subcategory": { + "description": "The subcategory of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "urgency": { + "description": "The extent to which the incident resolution can be delayed for ServiceNow ITSM connectors.", + "type": "string" + } + }, + "required": [ + "fields", + "id", + "name", + "type" + ] + }, + "id": { + "description": "The identifier for the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "name": { + "description": "The name of the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/connector_types" + } + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2022-05-13T09:16:17.416Z" + }, + "created_by": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "ahunley@imf.usa.gov" + }, + "full_name": { + "type": "string", + "example": "Alan Hunley" + }, + "username": { + "type": "string", + "example": "ahunley" + } + } + }, + "description": { + "type": "string", + "example": "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": { + "type": "integer", + "description": "The elapsed time from the creation of the case to its closure (in seconds). If the case has not been closed, the duration is set to null.", + "example": 120 + }, + "external_service": { + "type": "object", + "properties": { + "connector_id": { + "type": "string" + }, + "connector_name": { + "type": "string" + }, + "external_id": { + "type": "string" + }, + "external_title": { + "type": "string" + }, + "external_url": { + "type": "string" + }, + "pushed_at": { + "type": "string", + "format": "date-time" + }, + "pushed_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + } + } + }, + "id": { + "type": "string", + "example": "66b9aa00-94fa-11ea-9f74-e7e108796192" + }, + "owner": { + "$ref": "#/components/schemas/owners" + }, + "settings": { + "type": "object", + "properties": { + "syncAlerts": { + "type": "boolean", + "example": true + } + } + }, + "status": { + "$ref": "#/components/schemas/status" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "phishing", + "social engineering", + "bubblegum" + ] + }, + "title": { + "type": "string", + "example": "This case will self-destruct in 5 seconds" + }, + "totalAlerts": { + "type": "integer", + "example": 0 + }, + "totalComment": { + "type": "integer", + "example": 0 + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "example": null + }, + "updated_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + }, + "version": { + "type": "string", + "example": "WzUzMiwxXQ==" + } + } + }, + "examples": { + "updateCaseResponse": { + "$ref": "#/components/examples/update_case_response" + } + } + } + } + } + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, + "/s/{spaceId}/api/cases": { + "post": { + "description": "Creates a case. You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're creating.\n", + "tags": [ + "cases", + "kibana" + ], + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + }, + { + "$ref": "#/components/parameters/space_id" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connector": { + "description": "An object that contains the connector configuration.", + "type": "object", + "properties": { + "fields": { + "description": "An object containing the connector fields. To create a case without a connector, specify null. If you want to omit any individual field, specify null as its value.", + "nullable": true, + "type": "object", + "properties": { + "caseId": { + "description": "The case identifier for Swimlane connectors.", + "type": "string" + }, + "category": { + "description": "The category of the incident for ServiceNow ITSM and ServiceNow SecOps connectors.", + "type": "string" + }, + "destIp": { + "description": "A comma-separated list of destination IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "impact": { + "description": "The effect an incident had on business for ServiceNow ITSM connectors.", + "type": "string" + }, + "issueType": { + "description": "The type of issue for Jira connectors.", + "type": "string" + }, + "issueTypes": { + "description": "The type of incident for IBM Resilient connectors.", + "type": "array", + "items": { + "type": "number" + } + }, + "malwareHash": { + "description": "A comma-separated list of malware hashes for ServiceNow SecOps connectors.", + "type": "string" + }, + "malwareUrl": { + "description": "A comma-separated list of malware URLs for ServiceNow SecOps connectors.", + "type": "string" + }, + "parent": { + "description": "The key of the parent issue, when the issue type is sub-task for Jira connectors.", + "type": "string" + }, + "priority": { + "description": "The priority of the issue for Jira and ServiceNow SecOps connectors.", + "type": "string" + }, + "severity": { + "description": "The severity of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "severityCode": { + "description": "The severity code of the incident for IBM Resilient connectors.", + "type": "number" + }, + "sourceIp": { + "description": "A comma-separated list of source IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "subcategory": { + "description": "The subcategory of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "urgency": { + "description": "The extent to which the incident resolution can be delayed for ServiceNow ITSM connectors.", + "type": "string" + } + }, + "required": [ + "fields", + "id", + "name", + "type" + ] + }, + "id": { + "description": "The identifier for the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "name": { + "description": "The name of the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/connector_types" + } + } + }, + "description": { + "description": "The description for the case.", + "type": "string" + }, + "owner": { + "$ref": "#/components/schemas/owners" + }, + "settings": { + "description": "An object that contains the case settings.", + "type": "object", + "properties": { + "syncAlerts": { + "description": "Turns alert syncing on or off.", + "type": "boolean" + } + } + }, + "tags": { + "description": "The words and phrases that help categorize cases. It can be an empty array.", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "A title for the case.", + "type": "string" + } + }, + "required": [ + "connector", + "description", + "owner", + "settings", + "tags", + "title" + ] + }, + "examples": { + "createCaseRequest": { + "$ref": "#/components/examples/create_case_request" + } + } + } + } + }, + "responses": { + "200": { + "description": "Indicates a successful call.", + "content": { + "application/json; charset=utf-8": { + "schema": { + "type": "object", + "properties": { + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "example": null + }, + "closed_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + }, + "comments": { + "type": "array", + "items": { + "type": "string" + }, + "example": [] + }, + "connector": { + "type": "object", + "properties": { + "fields": { + "description": "An object containing the connector fields. To create a case without a connector, specify null. If you want to omit any individual field, specify null as its value.", + "nullable": true, + "type": "object", + "properties": { + "caseId": { + "description": "The case identifier for Swimlane connectors.", + "type": "string" + }, + "category": { + "description": "The category of the incident for ServiceNow ITSM and ServiceNow SecOps connectors.", + "type": "string" + }, + "destIp": { + "description": "A comma-separated list of destination IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "impact": { + "description": "The effect an incident had on business for ServiceNow ITSM connectors.", + "type": "string" + }, + "issueType": { + "description": "The type of issue for Jira connectors.", + "type": "string" + }, + "issueTypes": { + "description": "The type of incident for IBM Resilient connectors.", + "type": "array", + "items": { + "type": "number" + } + }, + "malwareHash": { + "description": "A comma-separated list of malware hashes for ServiceNow SecOps connectors.", + "type": "string" + }, + "malwareUrl": { + "description": "A comma-separated list of malware URLs for ServiceNow SecOps connectors.", + "type": "string" + }, + "parent": { + "description": "The key of the parent issue, when the issue type is sub-task for Jira connectors.", + "type": "string" + }, + "priority": { + "description": "The priority of the issue for Jira and ServiceNow SecOps connectors.", + "type": "string" + }, + "severity": { + "description": "The severity of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "severityCode": { + "description": "The severity code of the incident for IBM Resilient connectors.", + "type": "number" + }, + "sourceIp": { + "description": "A comma-separated list of source IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "subcategory": { + "description": "The subcategory of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "urgency": { + "description": "The extent to which the incident resolution can be delayed for ServiceNow ITSM connectors.", + "type": "string" + } + }, + "required": [ + "fields", + "id", + "name", + "type" + ] + }, + "id": { + "description": "The identifier for the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "name": { + "description": "The name of the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/connector_types" + } + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2022-05-13T09:16:17.416Z" + }, + "created_by": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "ahunley@imf.usa.gov" + }, + "full_name": { + "type": "string", + "example": "Alan Hunley" + }, + "username": { + "type": "string", + "example": "ahunley" + } + } + }, + "description": { + "type": "string", + "example": "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": { + "type": "integer", + "description": "The elapsed time from the creation of the case to its closure (in seconds). If the case has not been closed, the duration is set to null.", + "example": 120 + }, + "external_service": { + "type": "object", + "properties": { + "connector_id": { + "type": "string" + }, + "connector_name": { + "type": "string" + }, + "external_id": { + "type": "string" + }, + "external_title": { + "type": "string" + }, + "external_url": { + "type": "string" + }, + "pushed_at": { + "type": "string", + "format": "date-time" + }, + "pushed_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + } + } + }, + "id": { + "type": "string", + "example": "66b9aa00-94fa-11ea-9f74-e7e108796192" + }, + "owner": { + "$ref": "#/components/schemas/owners" + }, + "settings": { + "type": "object", + "properties": { + "syncAlerts": { + "type": "boolean", + "example": true + } + } + }, + "status": { + "$ref": "#/components/schemas/status" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "phishing", + "social engineering", + "bubblegum" + ] + }, + "title": { + "type": "string", + "example": "This case will self-destruct in 5 seconds" + }, + "totalAlerts": { + "type": "integer", + "example": 0 + }, + "totalComment": { + "type": "integer", + "example": 0 + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "example": null + }, + "updated_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + }, + "version": { + "type": "string", + "example": "WzUzMiwxXQ==" + } + } + }, + "examples": { + "createCaseResponse": { + "$ref": "#/components/examples/create_case_response" + } + } + } + } + } + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, + "delete": { + "description": "Deletes one or more cases. You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the cases you're deleting.\n", + "tags": [ + "cases", + "kibana" + ], + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + }, + { + "$ref": "#/components/parameters/space_id" + }, + { + "name": "ids", + "description": "The cases that you want to removed. All non-ASCII characters must be URL encoded.", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "example": "d4e7abb0-b462-11ec-9a8d-698504725a43" + } + ], + "responses": { + "204": { + "description": "Indicates a successful call." + } + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, + "patch": { + "description": "Updates one or more cases. You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're updating.\n", + "tags": [ + "cases", + "kibana" + ], + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + }, + { + "$ref": "#/components/parameters/space_id" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "cases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "connector": { + "description": "An object that contains the connector configuration.", + "type": "object", + "properties": { + "fields": { + "description": "An object containing the connector fields. To create a case without a connector, specify null. If you want to omit any individual field, specify null as its value.", + "nullable": true, + "type": "object", + "properties": { + "caseId": { + "description": "The case identifier for Swimlane connectors.", + "type": "string" + }, + "category": { + "description": "The category of the incident for ServiceNow ITSM and ServiceNow SecOps connectors.", + "type": "string" + }, + "destIp": { + "description": "A comma-separated list of destination IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "impact": { + "description": "The effect an incident had on business for ServiceNow ITSM connectors.", + "type": "string" + }, + "issueType": { + "description": "The type of issue for Jira connectors.", + "type": "string" + }, + "issueTypes": { + "description": "The type of incident for IBM Resilient connectors.", + "type": "array", + "items": { + "type": "number" + } + }, + "malwareHash": { + "description": "A comma-separated list of malware hashes for ServiceNow SecOps connectors.", + "type": "string" + }, + "malwareUrl": { + "description": "A comma-separated list of malware URLs for ServiceNow SecOps connectors.", + "type": "string" + }, + "parent": { + "description": "The key of the parent issue, when the issue type is sub-task for Jira connectors.", + "type": "string" + }, + "priority": { + "description": "The priority of the issue for Jira and ServiceNow SecOps connectors.", + "type": "string" + }, + "severity": { + "description": "The severity of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "severityCode": { + "description": "The severity code of the incident for IBM Resilient connectors.", + "type": "number" + }, + "sourceIp": { + "description": "A comma-separated list of source IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "subcategory": { + "description": "The subcategory of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "urgency": { + "description": "The extent to which the incident resolution can be delayed for ServiceNow ITSM connectors.", + "type": "string" + } + }, + "required": [ + "fields", + "id", + "name", + "type" + ] + }, + "id": { + "description": "The identifier for the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "name": { + "description": "The name of the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/connector_types" + } + } + }, + "description": { + "description": "The description for the case.", + "type": "string" + }, + "id": { + "description": "The identifier for the case.", + "type": "string" + }, + "settings": { + "description": "An object that contains the case settings.", + "type": "object", + "properties": { + "syncAlerts": { + "description": "Turns alert syncing on or off.", + "type": "boolean" + } + } + }, + "status": { + "$ref": "#/components/schemas/status" + }, + "tags": { + "description": "The words and phrases that help categorize cases.", + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "description": "A title for the case.", + "type": "string" + }, + "version": { + "description": "The current version of the case.", + "type": "string" + } + }, + "required": [ + "id", + "version" + ] + } + } + } + }, + "examples": { + "updateCaseRequest": { + "$ref": "#/components/examples/update_case_request" + } + } + } + } + }, + "responses": { + "200": { + "description": "Indicates a successful call.", + "content": { + "application/json; charset=utf-8": { + "schema": { + "type": "object", + "properties": { + "closed_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "example": null + }, + "closed_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + }, + "comments": { + "type": "array", + "items": { + "type": "string" + }, + "example": [] + }, + "connector": { + "type": "object", + "properties": { + "fields": { + "description": "An object containing the connector fields. To create a case without a connector, specify null. If you want to omit any individual field, specify null as its value.", + "nullable": true, + "type": "object", + "properties": { + "caseId": { + "description": "The case identifier for Swimlane connectors.", + "type": "string" + }, + "category": { + "description": "The category of the incident for ServiceNow ITSM and ServiceNow SecOps connectors.", + "type": "string" + }, + "destIp": { + "description": "A comma-separated list of destination IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "impact": { + "description": "The effect an incident had on business for ServiceNow ITSM connectors.", + "type": "string" + }, + "issueType": { + "description": "The type of issue for Jira connectors.", + "type": "string" + }, + "issueTypes": { + "description": "The type of incident for IBM Resilient connectors.", + "type": "array", + "items": { + "type": "number" + } + }, + "malwareHash": { + "description": "A comma-separated list of malware hashes for ServiceNow SecOps connectors.", + "type": "string" + }, + "malwareUrl": { + "description": "A comma-separated list of malware URLs for ServiceNow SecOps connectors.", + "type": "string" + }, + "parent": { + "description": "The key of the parent issue, when the issue type is sub-task for Jira connectors.", + "type": "string" + }, + "priority": { + "description": "The priority of the issue for Jira and ServiceNow SecOps connectors.", + "type": "string" + }, + "severity": { + "description": "The severity of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "severityCode": { + "description": "The severity code of the incident for IBM Resilient connectors.", + "type": "number" + }, + "sourceIp": { + "description": "A comma-separated list of source IPs for ServiceNow SecOps connectors.", + "type": "string" + }, + "subcategory": { + "description": "The subcategory of the incident for ServiceNow ITSM connectors.", + "type": "string" + }, + "urgency": { + "description": "The extent to which the incident resolution can be delayed for ServiceNow ITSM connectors.", + "type": "string" + } + }, + "required": [ + "fields", + "id", + "name", + "type" + ] + }, + "id": { + "description": "The identifier for the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "name": { + "description": "The name of the connector. To create a case without a connector, use `none`.", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/connector_types" + } + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2022-05-13T09:16:17.416Z" + }, + "created_by": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "ahunley@imf.usa.gov" + }, + "full_name": { + "type": "string", + "example": "Alan Hunley" + }, + "username": { + "type": "string", + "example": "ahunley" + } + } + }, + "description": { + "type": "string", + "example": "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": { + "type": "integer", + "description": "The elapsed time from the creation of the case to its closure (in seconds). If the case has not been closed, the duration is set to null.", + "example": 120 + }, + "external_service": { + "type": "object", + "properties": { + "connector_id": { + "type": "string" + }, + "connector_name": { + "type": "string" + }, + "external_id": { + "type": "string" + }, + "external_title": { + "type": "string" + }, + "external_url": { + "type": "string" + }, + "pushed_at": { + "type": "string", + "format": "date-time" + }, + "pushed_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + } + } + }, + "id": { + "type": "string", + "example": "66b9aa00-94fa-11ea-9f74-e7e108796192" + }, + "owner": { + "$ref": "#/components/schemas/owners" + }, + "settings": { + "type": "object", + "properties": { + "syncAlerts": { + "type": "boolean", + "example": true + } + } + }, + "status": { + "$ref": "#/components/schemas/status" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "phishing", + "social engineering", + "bubblegum" + ] + }, + "title": { + "type": "string", + "example": "This case will self-destruct in 5 seconds" + }, + "totalAlerts": { + "type": "integer", + "example": 0 + }, + "totalComment": { + "type": "integer", + "example": 0 + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true, + "example": null + }, + "updated_by": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "full_name": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "nullable": true, + "example": null + }, + "version": { + "type": "string", + "example": "WzUzMiwxXQ==" + } + } + }, + "examples": { + "updateCaseResponse": { + "$ref": "#/components/examples/update_case_response" + } + } + } + } + } + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + }, + "servers": [ + { + "url": "https://localhost:5601" + } + ] + } + }, + "components": { + "securitySchemes": { + "basicAuth": { + "type": "http", + "scheme": "basic" + }, + "apiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "ApiKey" + } + }, + "parameters": { + "kbn_xsrf": { + "schema": { + "type": "string" + }, + "in": "header", + "name": "kbn-xsrf", + "required": true + }, + "space_id": { + "in": "path", + "name": "spaceId", + "description": "An identifier for the space.", + "required": true, + "schema": { + "type": "string", + "example": "default" + } + } + }, + "schemas": { + "connector_types": { + "type": "string", + "description": "The type of connector.", + "enum": [ + ".jira", + ".none", + ".resilient", + ".servicenow", + ".servicenow-sir", + ".swimlane" + ] + }, + "owners": { + "type": "string", + "description": "Owner apps", + "enum": [ + "cases", + "observability", + "securitySolution" + ] + }, + "status": { + "type": "string", + "description": "The status of the case.", + "enum": [ + "closed", + "in-progress", + "open" + ] + } + }, + "examples": { + "create_case_request": { + "summary": "Create a security case that uses a Jira connector.", + "value": { + "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants.", + "title": "This case will self-destruct in 5 seconds", + "tags": [ + "phishing", + "social engineering" + ], + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", + "name": "My connector", + "type": ".jira", + "fields": { + "issueType": "10006", + "priority": "High", + "parent": null + } + }, + "settings": { + "syncAlerts": true + }, + "owner": "securitySolution" + } + }, + "create_case_response": { + "summary": "The create case API returns a JSON object that includes the user who created the case and the case identifier, version, and creation time.", + "value": { + "id": "66b9aa00-94fa-11ea-9f74-e7e108796192", + "version": "WzUzMiwxXQ==", + "comments": [], + "totalComment": 0, + "totalAlerts": 0, + "title": "This case will self-destruct in 5 seconds", + "tags": [ + "phishing", + "social engineering", + "bubblegum" + ], + "settings": { + "syncAlerts": true + }, + "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, + "closed_at": null, + "closed_by": null, + "created_at": "2022-05-13T09:16:17.416Z", + "created_by": { + "email": "ahunley@imf.usa.gov", + "full_name": "Alan Hunley", + "username": "ahunley" + }, + "status": "open", + "updated_at": null, + "updated_by": null, + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", + "name": "My connector", + "type": ".jira", + "fields": { + "issueType": "10006", + "parent": null, + "priority": "High" + } + }, + "external_service": null + } + }, + "update_case_request": { + "summary": "Update the case description, tags, and connector.", + "value": { + "cases": [ + { + "id": "a18b38a0-71b0-11ea-a0b2-c51ea50a58e2", + "version": "WzIzLDFd", + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", + "name": "My connector", + "type": ".jira", + "fields": { + "issueType": "10006", + "priority": null, + "parent": null + } + }, + "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!", + "tags": [ + "phishing", + "social engineering", + "bubblegum" + ], + "settings": { + "syncAlerts": true + } + } + ] + } + }, + "update_case_response": { + "summary": "This is an example response when the case description, tags, and connector were updated.", + "value": [ + { + "id": "66b9aa00-94fa-11ea-9f74-e7e108796192", + "version": "WzU0OCwxXQ==", + "comments": [], + "totalComment": 0, + "totalAlerts": 0, + "title": "This case will self-destruct in 5 seconds", + "tags": [ + "phishing", + "social engineering", + "bubblegum" + ], + "settings": { + "syncAlerts": true + }, + "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, + "closed_at": null, + "closed_by": null, + "created_at": "2022-05-13T09:16:17.416Z", + "created_by": { + "email": "ahunley@imf.usa.gov", + "full_name": "Alan Hunley", + "username": "ahunley" + }, + "status": "open", + "updated_at": "2022-05-13T09:48:33.043Z", + "updated_by": { + "email": "classified@hms.oo.gov.uk", + "full_name": "Classified", + "username": "M" + }, + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", + "name": "My connector", + "type": ".jira", + "fields": { + "issueType": "10006", + "parent": null, + "priority": null + } + }, + "external_service": { + "external_title": "IS-4", + "pushed_by": { + "full_name": "Classified", + "email": "classified@hms.oo.gov.uk", + "username": "M" + }, + "external_url": "https://hms.atlassian.net/browse/IS-4", + "pushed_at": "2022-05-13T09:20:40.672Z", + "connector_id": "05da469f-1fde-4058-99a3-91e4807e2de8", + "external_id": "10003", + "connector_name": "Jira" + } + } + ] + } + } + }, + "security": [ + { + "basicAuth": [] + }, + { + "apiKeyAuth": [] + } + ] +} \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/bundled.yaml b/x-pack/plugins/cases/docs/openapi/bundled.yaml new file mode 100644 index 000000000000000..083aef3c25ad254 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/bundled.yaml @@ -0,0 +1,1841 @@ +openapi: 3.0.1 +info: + title: Cases + description: OpenAPI schema for Cases endpoints + version: '0.1' + contact: + name: Cases Team + license: + name: Elastic License 2.0 + url: https://www.elastic.co/licensing/elastic-license +tags: + - name: cases + description: Case APIs enable you to open and track issues. + - name: kibana + description: Kibana APIs enable you to interact with Kibana features. +servers: + - url: http://localhost:5601 + description: local +paths: + /api/cases: + post: + description: > + Creates a case. You must have all privileges for the **Cases** feature + in the **Management**, **Observability**, or **Security** section of the + Kibana feature privileges, depending on the owner of the case you're + creating. + tags: + - cases + - kibana + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + requestBody: + content: + application/json: + schema: + type: object + properties: + connector: + description: An object that contains the connector configuration. + type: object + properties: + fields: + description: >- + An object containing the connector fields. To create a + case without a connector, specify null. If you want to + omit any individual field, specify null as its value. + nullable: true + type: object + properties: + caseId: + description: The case identifier for Swimlane connectors. + type: string + category: + description: >- + The category of the incident for ServiceNow ITSM and + ServiceNow SecOps connectors. + type: string + destIp: + description: >- + A comma-separated list of destination IPs for + ServiceNow SecOps connectors. + type: string + impact: + description: >- + The effect an incident had on business for + ServiceNow ITSM connectors. + type: string + issueType: + description: The type of issue for Jira connectors. + type: string + issueTypes: + description: The type of incident for IBM Resilient connectors. + type: array + items: + type: number + malwareHash: + description: >- + A comma-separated list of malware hashes for + ServiceNow SecOps connectors. + type: string + malwareUrl: + description: >- + A comma-separated list of malware URLs for + ServiceNow SecOps connectors. + type: string + parent: + description: >- + The key of the parent issue, when the issue type is + sub-task for Jira connectors. + type: string + priority: + description: >- + The priority of the issue for Jira and ServiceNow + SecOps connectors. + type: string + severity: + description: >- + The severity of the incident for ServiceNow ITSM + connectors. + type: string + severityCode: + description: >- + The severity code of the incident for IBM Resilient + connectors. + type: number + sourceIp: + description: >- + A comma-separated list of source IPs for ServiceNow + SecOps connectors. + type: string + subcategory: + description: >- + The subcategory of the incident for ServiceNow ITSM + connectors. + type: string + urgency: + description: >- + The extent to which the incident resolution can be + delayed for ServiceNow ITSM connectors. + type: string + required: + - fields + - id + - name + - type + id: + description: >- + The identifier for the connector. To create a case + without a connector, use `none`. + type: string + name: + description: >- + The name of the connector. To create a case without a + connector, use `none`. + type: string + type: + $ref: '#/components/schemas/connector_types' + description: + description: The description for the case. + type: string + owner: + $ref: '#/components/schemas/owners' + settings: + description: An object that contains the case settings. + type: object + properties: + syncAlerts: + description: Turns alert syncing on or off. + type: boolean + tags: + description: >- + The words and phrases that help categorize cases. It can be + an empty array. + type: array + items: + type: string + title: + description: A title for the case. + type: string + required: + - connector + - description + - owner + - settings + - tags + - title + examples: + createCaseRequest: + $ref: '#/components/examples/create_case_request' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + type: object + properties: + closed_at: + type: string + format: date-time + nullable: true + example: null + closed_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + comments: + type: array + items: + type: string + example: [] + connector: + type: object + properties: + fields: + description: >- + An object containing the connector fields. To create a + case without a connector, specify null. If you want to + omit any individual field, specify null as its value. + nullable: true + type: object + properties: + caseId: + description: The case identifier for Swimlane connectors. + type: string + category: + description: >- + The category of the incident for ServiceNow ITSM + and ServiceNow SecOps connectors. + type: string + destIp: + description: >- + A comma-separated list of destination IPs for + ServiceNow SecOps connectors. + type: string + impact: + description: >- + The effect an incident had on business for + ServiceNow ITSM connectors. + type: string + issueType: + description: The type of issue for Jira connectors. + type: string + issueTypes: + description: The type of incident for IBM Resilient connectors. + type: array + items: + type: number + malwareHash: + description: >- + A comma-separated list of malware hashes for + ServiceNow SecOps connectors. + type: string + malwareUrl: + description: >- + A comma-separated list of malware URLs for + ServiceNow SecOps connectors. + type: string + parent: + description: >- + The key of the parent issue, when the issue type + is sub-task for Jira connectors. + type: string + priority: + description: >- + The priority of the issue for Jira and ServiceNow + SecOps connectors. + type: string + severity: + description: >- + The severity of the incident for ServiceNow ITSM + connectors. + type: string + severityCode: + description: >- + The severity code of the incident for IBM + Resilient connectors. + type: number + sourceIp: + description: >- + A comma-separated list of source IPs for + ServiceNow SecOps connectors. + type: string + subcategory: + description: >- + The subcategory of the incident for ServiceNow + ITSM connectors. + type: string + urgency: + description: >- + The extent to which the incident resolution can be + delayed for ServiceNow ITSM connectors. + type: string + required: + - fields + - id + - name + - type + id: + description: >- + The identifier for the connector. To create a case + without a connector, use `none`. + type: string + name: + description: >- + The name of the connector. To create a case without a + connector, use `none`. + type: string + type: + $ref: '#/components/schemas/connector_types' + created_at: + type: string + format: date-time + example: '2022-05-13T09:16:17.416Z' + created_by: + type: object + properties: + email: + type: string + example: ahunley@imf.usa.gov + full_name: + type: string + example: Alan Hunley + username: + type: string + example: ahunley + description: + type: string + example: >- + 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: + type: integer + description: >- + The elapsed time from the creation of the case to its + closure (in seconds). If the case has not been closed, the + duration is set to null. + example: 120 + external_service: + type: object + properties: + connector_id: + type: string + connector_name: + type: string + external_id: + type: string + external_title: + type: string + external_url: + type: string + pushed_at: + type: string + format: date-time + pushed_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + id: + type: string + example: 66b9aa00-94fa-11ea-9f74-e7e108796192 + owner: + $ref: '#/components/schemas/owners' + settings: + type: object + properties: + syncAlerts: + type: boolean + example: true + status: + $ref: '#/components/schemas/status' + tags: + type: array + items: + type: string + example: + - phishing + - social engineering + - bubblegum + title: + type: string + example: This case will self-destruct in 5 seconds + totalAlerts: + type: integer + example: 0 + totalComment: + type: integer + example: 0 + updated_at: + type: string + format: date-time + nullable: true + example: null + updated_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + version: + type: string + example: WzUzMiwxXQ== + examples: + createCaseResponse: + $ref: '#/components/examples/create_case_response' + servers: + - url: https://localhost:5601 + delete: + description: > + Deletes one or more cases. You must have all privileges for the + **Cases** feature in the **Management**, **Observability**, or + **Security** section of the Kibana feature privileges, depending on the + owner of the cases you're deleting. + tags: + - cases + - kibana + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + - name: ids + description: >- + The cases that you want to removed. To retrieve case IDs, use the + find cases API. All non-ASCII characters must be URL encoded. + in: query + required: true + schema: + type: string + example: d4e7abb0-b462-11ec-9a8d-698504725a43 + responses: + '204': + description: Indicates a successful call. + servers: + - url: https://localhost:5601 + patch: + description: > + Updates one or more cases. You must have all privileges for the + **Cases** feature in the **Management**, **Observability**, or + **Security** section of the Kibana feature privileges, depending on the + owner of the case you're updating. + tags: + - cases + - kibana + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + requestBody: + content: + application/json: + schema: + type: object + properties: + cases: + type: array + items: + type: object + properties: + connector: + description: An object that contains the connector configuration. + type: object + properties: + fields: + description: >- + An object containing the connector fields. To + create a case without a connector, specify null. + If you want to omit any individual field, specify + null as its value. + nullable: true + type: object + properties: + caseId: + description: The case identifier for Swimlane connectors. + type: string + category: + description: >- + The category of the incident for ServiceNow + ITSM and ServiceNow SecOps connectors. + type: string + destIp: + description: >- + A comma-separated list of destination IPs for + ServiceNow SecOps connectors. + type: string + impact: + description: >- + The effect an incident had on business for + ServiceNow ITSM connectors. + type: string + issueType: + description: The type of issue for Jira connectors. + type: string + issueTypes: + description: >- + The type of incident for IBM Resilient + connectors. + type: array + items: + type: number + malwareHash: + description: >- + A comma-separated list of malware hashes for + ServiceNow SecOps connectors. + type: string + malwareUrl: + description: >- + A comma-separated list of malware URLs for + ServiceNow SecOps connectors. + type: string + parent: + description: >- + The key of the parent issue, when the issue + type is sub-task for Jira connectors. + type: string + priority: + description: >- + The priority of the issue for Jira and + ServiceNow SecOps connectors. + type: string + severity: + description: >- + The severity of the incident for ServiceNow + ITSM connectors. + type: string + severityCode: + description: >- + The severity code of the incident for IBM + Resilient connectors. + type: number + sourceIp: + description: >- + A comma-separated list of source IPs for + ServiceNow SecOps connectors. + type: string + subcategory: + description: >- + The subcategory of the incident for ServiceNow + ITSM connectors. + type: string + urgency: + description: >- + The extent to which the incident resolution + can be delayed for ServiceNow ITSM connectors. + type: string + required: + - fields + - id + - name + - type + id: + description: >- + The identifier for the connector. To create a case + without a connector, use `none`. + type: string + name: + description: >- + The name of the connector. To create a case + without a connector, use `none`. + type: string + type: + $ref: '#/components/schemas/connector_types' + description: + description: The description for the case. + type: string + id: + description: The identifier for the case. + type: string + settings: + description: An object that contains the case settings. + type: object + properties: + syncAlerts: + description: Turns alert syncing on or off. + type: boolean + status: + $ref: '#/components/schemas/status' + tags: + description: The words and phrases that help categorize cases. + type: array + items: + type: string + title: + description: A title for the case. + type: string + version: + description: The current version of the case. + type: string + required: + - id + - version + examples: + updateCaseRequest: + $ref: '#/components/examples/update_case_request' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + type: object + properties: + closed_at: + type: string + format: date-time + nullable: true + example: null + closed_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + comments: + type: array + items: + type: string + example: [] + connector: + type: object + properties: + fields: + description: >- + An object containing the connector fields. To create a + case without a connector, specify null. If you want to + omit any individual field, specify null as its value. + nullable: true + type: object + properties: + caseId: + description: The case identifier for Swimlane connectors. + type: string + category: + description: >- + The category of the incident for ServiceNow ITSM + and ServiceNow SecOps connectors. + type: string + destIp: + description: >- + A comma-separated list of destination IPs for + ServiceNow SecOps connectors. + type: string + impact: + description: >- + The effect an incident had on business for + ServiceNow ITSM connectors. + type: string + issueType: + description: The type of issue for Jira connectors. + type: string + issueTypes: + description: The type of incident for IBM Resilient connectors. + type: array + items: + type: number + malwareHash: + description: >- + A comma-separated list of malware hashes for + ServiceNow SecOps connectors. + type: string + malwareUrl: + description: >- + A comma-separated list of malware URLs for + ServiceNow SecOps connectors. + type: string + parent: + description: >- + The key of the parent issue, when the issue type + is sub-task for Jira connectors. + type: string + priority: + description: >- + The priority of the issue for Jira and ServiceNow + SecOps connectors. + type: string + severity: + description: >- + The severity of the incident for ServiceNow ITSM + connectors. + type: string + severityCode: + description: >- + The severity code of the incident for IBM + Resilient connectors. + type: number + sourceIp: + description: >- + A comma-separated list of source IPs for + ServiceNow SecOps connectors. + type: string + subcategory: + description: >- + The subcategory of the incident for ServiceNow + ITSM connectors. + type: string + urgency: + description: >- + The extent to which the incident resolution can be + delayed for ServiceNow ITSM connectors. + type: string + required: + - fields + - id + - name + - type + id: + description: >- + The identifier for the connector. To create a case + without a connector, use `none`. + type: string + name: + description: >- + The name of the connector. To create a case without a + connector, use `none`. + type: string + type: + $ref: '#/components/schemas/connector_types' + created_at: + type: string + format: date-time + example: '2022-05-13T09:16:17.416Z' + created_by: + type: object + properties: + email: + type: string + example: ahunley@imf.usa.gov + full_name: + type: string + example: Alan Hunley + username: + type: string + example: ahunley + description: + type: string + example: >- + 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: + type: integer + description: >- + The elapsed time from the creation of the case to its + closure (in seconds). If the case has not been closed, the + duration is set to null. + example: 120 + external_service: + type: object + properties: + connector_id: + type: string + connector_name: + type: string + external_id: + type: string + external_title: + type: string + external_url: + type: string + pushed_at: + type: string + format: date-time + pushed_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + id: + type: string + example: 66b9aa00-94fa-11ea-9f74-e7e108796192 + owner: + $ref: '#/components/schemas/owners' + settings: + type: object + properties: + syncAlerts: + type: boolean + example: true + status: + $ref: '#/components/schemas/status' + tags: + type: array + items: + type: string + example: + - phishing + - social engineering + - bubblegum + title: + type: string + example: This case will self-destruct in 5 seconds + totalAlerts: + type: integer + example: 0 + totalComment: + type: integer + example: 0 + updated_at: + type: string + format: date-time + nullable: true + example: null + updated_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + version: + type: string + example: WzUzMiwxXQ== + examples: + updateCaseResponse: + $ref: '#/components/examples/update_case_response' + servers: + - url: https://localhost:5601 + servers: + - url: https://localhost:5601 + /s/{spaceId}/api/cases: + post: + description: > + Creates a case. You must have all privileges for the **Cases** feature + in the **Management**, **Observability**, or **Security** section of the + Kibana feature privileges, depending on the owner of the case you're + creating. + tags: + - cases + - kibana + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/components/parameters/space_id' + requestBody: + content: + application/json: + schema: + type: object + properties: + connector: + description: An object that contains the connector configuration. + type: object + properties: + fields: + description: >- + An object containing the connector fields. To create a + case without a connector, specify null. If you want to + omit any individual field, specify null as its value. + nullable: true + type: object + properties: + caseId: + description: The case identifier for Swimlane connectors. + type: string + category: + description: >- + The category of the incident for ServiceNow ITSM and + ServiceNow SecOps connectors. + type: string + destIp: + description: >- + A comma-separated list of destination IPs for + ServiceNow SecOps connectors. + type: string + impact: + description: >- + The effect an incident had on business for + ServiceNow ITSM connectors. + type: string + issueType: + description: The type of issue for Jira connectors. + type: string + issueTypes: + description: The type of incident for IBM Resilient connectors. + type: array + items: + type: number + malwareHash: + description: >- + A comma-separated list of malware hashes for + ServiceNow SecOps connectors. + type: string + malwareUrl: + description: >- + A comma-separated list of malware URLs for + ServiceNow SecOps connectors. + type: string + parent: + description: >- + The key of the parent issue, when the issue type is + sub-task for Jira connectors. + type: string + priority: + description: >- + The priority of the issue for Jira and ServiceNow + SecOps connectors. + type: string + severity: + description: >- + The severity of the incident for ServiceNow ITSM + connectors. + type: string + severityCode: + description: >- + The severity code of the incident for IBM Resilient + connectors. + type: number + sourceIp: + description: >- + A comma-separated list of source IPs for ServiceNow + SecOps connectors. + type: string + subcategory: + description: >- + The subcategory of the incident for ServiceNow ITSM + connectors. + type: string + urgency: + description: >- + The extent to which the incident resolution can be + delayed for ServiceNow ITSM connectors. + type: string + required: + - fields + - id + - name + - type + id: + description: >- + The identifier for the connector. To create a case + without a connector, use `none`. + type: string + name: + description: >- + The name of the connector. To create a case without a + connector, use `none`. + type: string + type: + $ref: '#/components/schemas/connector_types' + description: + description: The description for the case. + type: string + owner: + $ref: '#/components/schemas/owners' + settings: + description: An object that contains the case settings. + type: object + properties: + syncAlerts: + description: Turns alert syncing on or off. + type: boolean + tags: + description: >- + The words and phrases that help categorize cases. It can be + an empty array. + type: array + items: + type: string + title: + description: A title for the case. + type: string + required: + - connector + - description + - owner + - settings + - tags + - title + examples: + createCaseRequest: + $ref: '#/components/examples/create_case_request' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + type: object + properties: + closed_at: + type: string + format: date-time + nullable: true + example: null + closed_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + comments: + type: array + items: + type: string + example: [] + connector: + type: object + properties: + fields: + description: >- + An object containing the connector fields. To create a + case without a connector, specify null. If you want to + omit any individual field, specify null as its value. + nullable: true + type: object + properties: + caseId: + description: The case identifier for Swimlane connectors. + type: string + category: + description: >- + The category of the incident for ServiceNow ITSM + and ServiceNow SecOps connectors. + type: string + destIp: + description: >- + A comma-separated list of destination IPs for + ServiceNow SecOps connectors. + type: string + impact: + description: >- + The effect an incident had on business for + ServiceNow ITSM connectors. + type: string + issueType: + description: The type of issue for Jira connectors. + type: string + issueTypes: + description: The type of incident for IBM Resilient connectors. + type: array + items: + type: number + malwareHash: + description: >- + A comma-separated list of malware hashes for + ServiceNow SecOps connectors. + type: string + malwareUrl: + description: >- + A comma-separated list of malware URLs for + ServiceNow SecOps connectors. + type: string + parent: + description: >- + The key of the parent issue, when the issue type + is sub-task for Jira connectors. + type: string + priority: + description: >- + The priority of the issue for Jira and ServiceNow + SecOps connectors. + type: string + severity: + description: >- + The severity of the incident for ServiceNow ITSM + connectors. + type: string + severityCode: + description: >- + The severity code of the incident for IBM + Resilient connectors. + type: number + sourceIp: + description: >- + A comma-separated list of source IPs for + ServiceNow SecOps connectors. + type: string + subcategory: + description: >- + The subcategory of the incident for ServiceNow + ITSM connectors. + type: string + urgency: + description: >- + The extent to which the incident resolution can be + delayed for ServiceNow ITSM connectors. + type: string + required: + - fields + - id + - name + - type + id: + description: >- + The identifier for the connector. To create a case + without a connector, use `none`. + type: string + name: + description: >- + The name of the connector. To create a case without a + connector, use `none`. + type: string + type: + $ref: '#/components/schemas/connector_types' + created_at: + type: string + format: date-time + example: '2022-05-13T09:16:17.416Z' + created_by: + type: object + properties: + email: + type: string + example: ahunley@imf.usa.gov + full_name: + type: string + example: Alan Hunley + username: + type: string + example: ahunley + description: + type: string + example: >- + 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: + type: integer + description: >- + The elapsed time from the creation of the case to its + closure (in seconds). If the case has not been closed, the + duration is set to null. + example: 120 + external_service: + type: object + properties: + connector_id: + type: string + connector_name: + type: string + external_id: + type: string + external_title: + type: string + external_url: + type: string + pushed_at: + type: string + format: date-time + pushed_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + id: + type: string + example: 66b9aa00-94fa-11ea-9f74-e7e108796192 + owner: + $ref: '#/components/schemas/owners' + settings: + type: object + properties: + syncAlerts: + type: boolean + example: true + status: + $ref: '#/components/schemas/status' + tags: + type: array + items: + type: string + example: + - phishing + - social engineering + - bubblegum + title: + type: string + example: This case will self-destruct in 5 seconds + totalAlerts: + type: integer + example: 0 + totalComment: + type: integer + example: 0 + updated_at: + type: string + format: date-time + nullable: true + example: null + updated_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + version: + type: string + example: WzUzMiwxXQ== + examples: + createCaseResponse: + $ref: '#/components/examples/create_case_response' + servers: + - url: https://localhost:5601 + delete: + description: > + Deletes one or more cases. You must have all privileges for the + **Cases** feature in the **Management**, **Observability**, or + **Security** section of the Kibana feature privileges, depending on the + owner of the cases you're deleting. + tags: + - cases + - kibana + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/components/parameters/space_id' + - name: ids + description: >- + The cases that you want to removed. All non-ASCII characters must be + URL encoded. + in: query + required: true + schema: + type: string + example: d4e7abb0-b462-11ec-9a8d-698504725a43 + responses: + '204': + description: Indicates a successful call. + servers: + - url: https://localhost:5601 + patch: + description: > + Updates one or more cases. You must have all privileges for the + **Cases** feature in the **Management**, **Observability**, or + **Security** section of the Kibana feature privileges, depending on the + owner of the case you're updating. + tags: + - cases + - kibana + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + - $ref: '#/components/parameters/space_id' + requestBody: + content: + application/json: + schema: + type: object + properties: + cases: + type: array + items: + type: object + properties: + connector: + description: An object that contains the connector configuration. + type: object + properties: + fields: + description: >- + An object containing the connector fields. To + create a case without a connector, specify null. + If you want to omit any individual field, specify + null as its value. + nullable: true + type: object + properties: + caseId: + description: The case identifier for Swimlane connectors. + type: string + category: + description: >- + The category of the incident for ServiceNow + ITSM and ServiceNow SecOps connectors. + type: string + destIp: + description: >- + A comma-separated list of destination IPs for + ServiceNow SecOps connectors. + type: string + impact: + description: >- + The effect an incident had on business for + ServiceNow ITSM connectors. + type: string + issueType: + description: The type of issue for Jira connectors. + type: string + issueTypes: + description: >- + The type of incident for IBM Resilient + connectors. + type: array + items: + type: number + malwareHash: + description: >- + A comma-separated list of malware hashes for + ServiceNow SecOps connectors. + type: string + malwareUrl: + description: >- + A comma-separated list of malware URLs for + ServiceNow SecOps connectors. + type: string + parent: + description: >- + The key of the parent issue, when the issue + type is sub-task for Jira connectors. + type: string + priority: + description: >- + The priority of the issue for Jira and + ServiceNow SecOps connectors. + type: string + severity: + description: >- + The severity of the incident for ServiceNow + ITSM connectors. + type: string + severityCode: + description: >- + The severity code of the incident for IBM + Resilient connectors. + type: number + sourceIp: + description: >- + A comma-separated list of source IPs for + ServiceNow SecOps connectors. + type: string + subcategory: + description: >- + The subcategory of the incident for ServiceNow + ITSM connectors. + type: string + urgency: + description: >- + The extent to which the incident resolution + can be delayed for ServiceNow ITSM connectors. + type: string + required: + - fields + - id + - name + - type + id: + description: >- + The identifier for the connector. To create a case + without a connector, use `none`. + type: string + name: + description: >- + The name of the connector. To create a case + without a connector, use `none`. + type: string + type: + $ref: '#/components/schemas/connector_types' + description: + description: The description for the case. + type: string + id: + description: The identifier for the case. + type: string + settings: + description: An object that contains the case settings. + type: object + properties: + syncAlerts: + description: Turns alert syncing on or off. + type: boolean + status: + $ref: '#/components/schemas/status' + tags: + description: The words and phrases that help categorize cases. + type: array + items: + type: string + title: + description: A title for the case. + type: string + version: + description: The current version of the case. + type: string + required: + - id + - version + examples: + updateCaseRequest: + $ref: '#/components/examples/update_case_request' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + type: object + properties: + closed_at: + type: string + format: date-time + nullable: true + example: null + closed_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + comments: + type: array + items: + type: string + example: [] + connector: + type: object + properties: + fields: + description: >- + An object containing the connector fields. To create a + case without a connector, specify null. If you want to + omit any individual field, specify null as its value. + nullable: true + type: object + properties: + caseId: + description: The case identifier for Swimlane connectors. + type: string + category: + description: >- + The category of the incident for ServiceNow ITSM + and ServiceNow SecOps connectors. + type: string + destIp: + description: >- + A comma-separated list of destination IPs for + ServiceNow SecOps connectors. + type: string + impact: + description: >- + The effect an incident had on business for + ServiceNow ITSM connectors. + type: string + issueType: + description: The type of issue for Jira connectors. + type: string + issueTypes: + description: The type of incident for IBM Resilient connectors. + type: array + items: + type: number + malwareHash: + description: >- + A comma-separated list of malware hashes for + ServiceNow SecOps connectors. + type: string + malwareUrl: + description: >- + A comma-separated list of malware URLs for + ServiceNow SecOps connectors. + type: string + parent: + description: >- + The key of the parent issue, when the issue type + is sub-task for Jira connectors. + type: string + priority: + description: >- + The priority of the issue for Jira and ServiceNow + SecOps connectors. + type: string + severity: + description: >- + The severity of the incident for ServiceNow ITSM + connectors. + type: string + severityCode: + description: >- + The severity code of the incident for IBM + Resilient connectors. + type: number + sourceIp: + description: >- + A comma-separated list of source IPs for + ServiceNow SecOps connectors. + type: string + subcategory: + description: >- + The subcategory of the incident for ServiceNow + ITSM connectors. + type: string + urgency: + description: >- + The extent to which the incident resolution can be + delayed for ServiceNow ITSM connectors. + type: string + required: + - fields + - id + - name + - type + id: + description: >- + The identifier for the connector. To create a case + without a connector, use `none`. + type: string + name: + description: >- + The name of the connector. To create a case without a + connector, use `none`. + type: string + type: + $ref: '#/components/schemas/connector_types' + created_at: + type: string + format: date-time + example: '2022-05-13T09:16:17.416Z' + created_by: + type: object + properties: + email: + type: string + example: ahunley@imf.usa.gov + full_name: + type: string + example: Alan Hunley + username: + type: string + example: ahunley + description: + type: string + example: >- + 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: + type: integer + description: >- + The elapsed time from the creation of the case to its + closure (in seconds). If the case has not been closed, the + duration is set to null. + example: 120 + external_service: + type: object + properties: + connector_id: + type: string + connector_name: + type: string + external_id: + type: string + external_title: + type: string + external_url: + type: string + pushed_at: + type: string + format: date-time + pushed_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + id: + type: string + example: 66b9aa00-94fa-11ea-9f74-e7e108796192 + owner: + $ref: '#/components/schemas/owners' + settings: + type: object + properties: + syncAlerts: + type: boolean + example: true + status: + $ref: '#/components/schemas/status' + tags: + type: array + items: + type: string + example: + - phishing + - social engineering + - bubblegum + title: + type: string + example: This case will self-destruct in 5 seconds + totalAlerts: + type: integer + example: 0 + totalComment: + type: integer + example: 0 + updated_at: + type: string + format: date-time + nullable: true + example: null + updated_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null + version: + type: string + example: WzUzMiwxXQ== + examples: + updateCaseResponse: + $ref: '#/components/examples/update_case_response' + servers: + - url: https://localhost:5601 + servers: + - url: https://localhost:5601 +components: + securitySchemes: + basicAuth: + type: http + scheme: basic + apiKeyAuth: + type: apiKey + in: header + name: ApiKey + parameters: + kbn_xsrf: + schema: + type: string + in: header + name: kbn-xsrf + required: true + space_id: + in: path + name: spaceId + description: An identifier for the space. + required: true + schema: + type: string + example: default + schemas: + connector_types: + type: string + description: The type of connector. + enum: + - .jira + - .none + - .resilient + - .servicenow + - .servicenow-sir + - .swimlane + owners: + type: string + description: Owner apps + enum: + - cases + - observability + - securitySolution + status: + type: string + description: The status of the case. + enum: + - closed + - in-progress + - open + examples: + create_case_request: + summary: Create a security case that uses a Jira connector. + value: + description: >- + James Bond clicked on a highly suspicious email banner advertising + cheap holidays for underpaid civil servants. + title: This case will self-destruct in 5 seconds + tags: + - phishing + - social engineering + connector: + id: 131d4448-abe0-4789-939d-8ef60680b498 + name: My connector + type: .jira + fields: + issueType: '10006' + priority: High + parent: null + settings: + syncAlerts: true + owner: securitySolution + create_case_response: + summary: >- + The create case API returns a JSON object that includes the user who + created the case and the case identifier, version, and creation time. + value: + id: 66b9aa00-94fa-11ea-9f74-e7e108796192 + version: WzUzMiwxXQ== + comments: [] + totalComment: 0 + totalAlerts: 0 + title: This case will self-destruct in 5 seconds + tags: + - phishing + - social engineering + - bubblegum + settings: + syncAlerts: true + 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 + closed_at: null + closed_by: null + created_at: '2022-05-13T09:16:17.416Z' + created_by: + email: ahunley@imf.usa.gov + full_name: Alan Hunley + username: ahunley + status: open + updated_at: null + updated_by: null + connector: + id: 131d4448-abe0-4789-939d-8ef60680b498 + name: My connector + type: .jira + fields: + issueType: '10006' + parent: null + priority: High + external_service: null + update_case_request: + summary: Update the case description, tags, and connector. + value: + cases: + - id: a18b38a0-71b0-11ea-a0b2-c51ea50a58e2 + version: WzIzLDFd + connector: + id: 131d4448-abe0-4789-939d-8ef60680b498 + name: My connector + type: .jira + fields: + issueType: '10006' + priority: null + parent: null + 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! + tags: + - phishing + - social engineering + - bubblegum + settings: + syncAlerts: true + update_case_response: + summary: >- + This is an example response when the case description, tags, and + connector were updated. + value: + - id: 66b9aa00-94fa-11ea-9f74-e7e108796192 + version: WzU0OCwxXQ== + comments: [] + totalComment: 0 + totalAlerts: 0 + title: This case will self-destruct in 5 seconds + tags: + - phishing + - social engineering + - bubblegum + settings: + syncAlerts: true + 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 + closed_at: null + closed_by: null + created_at: '2022-05-13T09:16:17.416Z' + created_by: + email: ahunley@imf.usa.gov + full_name: Alan Hunley + username: ahunley + status: open + updated_at: '2022-05-13T09:48:33.043Z' + updated_by: + email: classified@hms.oo.gov.uk + full_name: Classified + username: M + connector: + id: 131d4448-abe0-4789-939d-8ef60680b498 + name: My connector + type: .jira + fields: + issueType: '10006' + parent: null + priority: null + external_service: + external_title: IS-4 + pushed_by: + full_name: Classified + email: classified@hms.oo.gov.uk + username: M + external_url: https://hms.atlassian.net/browse/IS-4 + pushed_at: '2022-05-13T09:20:40.672Z' + connector_id: 05da469f-1fde-4058-99a3-91e4807e2de8 + external_id: '10003' + connector_name: Jira +security: + - basicAuth: [] + - apiKeyAuth: [] diff --git a/x-pack/plugins/cases/docs/openapi/components/README.md b/x-pack/plugins/cases/docs/openapi/components/README.md new file mode 100644 index 000000000000000..0841562a33150dd --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/README.md @@ -0,0 +1,7 @@ +Reusable components +=========== + + - `examples` - reusable [Example objects](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.2.md#exampleObject) + - `headers` - reusable [Header objects](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#headerObject) + - `parameters` - reusable [Parameter objects](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#parameterObject) + - `schemas` - reusable [Schema objects](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.2.md#schemaObject) diff --git a/x-pack/plugins/cases/docs/openapi/components/examples/create_case_request.yaml b/x-pack/plugins/cases/docs/openapi/components/examples/create_case_request.yaml new file mode 100644 index 000000000000000..0659ed18a856927 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/examples/create_case_request.yaml @@ -0,0 +1,21 @@ +summary: Create a security case that uses a Jira connector. +value: + { + "description": "James Bond clicked on a highly suspicious email banner advertising cheap holidays for underpaid civil servants.", + "title": "This case will self-destruct in 5 seconds", + "tags": [ "phishing","social engineering"], + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", + "name": "My connector", + "type": ".jira", + "fields": { + "issueType": "10006", + "priority": "High", + "parent": null + } + }, + "settings": { + "syncAlerts": true + }, + "owner": "securitySolution" + } 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 new file mode 100644 index 000000000000000..bc5fa1f5bc04925 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/examples/create_case_response.yaml @@ -0,0 +1,43 @@ +summary: The create case API returns a JSON object that includes the user who created the case and the case identifier, version, and creation time. +value: + { + "id": "66b9aa00-94fa-11ea-9f74-e7e108796192", + "version": "WzUzMiwxXQ==", + "comments": [], + "totalComment": 0, + "totalAlerts": 0, + "title": "This case will self-destruct in 5 seconds", + "tags": [ + "phishing", + "social engineering", + "bubblegum" + ], + "settings": { + "syncAlerts": true + }, + "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, + "closed_at": null, + "closed_by": null, + "created_at": "2022-05-13T09:16:17.416Z", + "created_by": { + "email": "ahunley@imf.usa.gov", + "full_name": "Alan Hunley", + "username": "ahunley" + }, + "status": "open", + "updated_at": null, + "updated_by": null, + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", + "name": "My connector", + "type": ".jira", + "fields": { + "issueType": "10006", + "parent": null, + "priority": "High" + } + }, + "external_service": null + } diff --git a/x-pack/plugins/cases/docs/openapi/components/examples/update_case_request.yaml b/x-pack/plugins/cases/docs/openapi/components/examples/update_case_request.yaml new file mode 100644 index 000000000000000..7ecb306cf0735fd --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/examples/update_case_request.yaml @@ -0,0 +1,29 @@ +summary: Update the case description, tags, and connector. +value: + { + "cases": [ + { + "id": "a18b38a0-71b0-11ea-a0b2-c51ea50a58e2", + "version": "WzIzLDFd", + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", + "name": "My connector", + "type": ".jira", + "fields": { + "issueType": "10006", + "priority": null, + "parent": null + } + }, + "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!", + "tags": [ + "phishing", + "social engineering", + "bubblegum" + ], + "settings": { + "syncAlerts": true + } + } + ] +} \ No newline at end of file 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 new file mode 100644 index 000000000000000..114669b893651f3 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/examples/update_case_response.yaml @@ -0,0 +1,61 @@ +summary: This is an example response when the case description, tags, and connector were updated. +value: + [ + { + "id": "66b9aa00-94fa-11ea-9f74-e7e108796192", + "version": "WzU0OCwxXQ==", + "comments": [], + "totalComment": 0, + "totalAlerts": 0, + "title": "This case will self-destruct in 5 seconds", + "tags": [ + "phishing", + "social engineering", + "bubblegum" + ], + "settings": { + "syncAlerts": true + }, + "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, + "closed_at": null, + "closed_by": null, + "created_at": "2022-05-13T09:16:17.416Z", + "created_by": { + "email": "ahunley@imf.usa.gov", + "full_name": "Alan Hunley", + "username": "ahunley" + }, + "status": "open", + "updated_at": "2022-05-13T09:48:33.043Z", + "updated_by": { + "email": "classified@hms.oo.gov.uk", + "full_name": "Classified", + "username": "M" + }, + "connector": { + "id": "131d4448-abe0-4789-939d-8ef60680b498", + "name": "My connector", + "type": ".jira", + "fields": { + "issueType": "10006", + "parent": null, + "priority": null, + } + }, + "external_service": { + "external_title": "IS-4", + "pushed_by": { + "full_name": "Classified", + "email": "classified@hms.oo.gov.uk", + "username": "M" + }, + "external_url": "https://hms.atlassian.net/browse/IS-4", + "pushed_at": "2022-05-13T09:20:40.672Z", + "connector_id": "05da469f-1fde-4058-99a3-91e4807e2de8", + "external_id": "10003", + "connector_name": "Jira" + } + } + ] \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/components/headers/kbn_xsrf.yaml b/x-pack/plugins/cases/docs/openapi/components/headers/kbn_xsrf.yaml new file mode 100644 index 000000000000000..3d8dfae634e68dc --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/headers/kbn_xsrf.yaml @@ -0,0 +1,5 @@ +schema: + type: string +in: header +name: kbn-xsrf +required: true diff --git a/x-pack/plugins/cases/docs/openapi/components/parameters/space_id.yaml b/x-pack/plugins/cases/docs/openapi/components/parameters/space_id.yaml new file mode 100644 index 000000000000000..0ff325b08a08218 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/parameters/space_id.yaml @@ -0,0 +1,7 @@ +in: path +name: spaceId +description: An identifier for the space. +required: true +schema: + type: string + example: default 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 new file mode 100644 index 000000000000000..6a2c3c3963c3c9e --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/case_response_properties.yaml @@ -0,0 +1,121 @@ +closed_at: + type: string + format: date-time + nullable: true + example: null +closed_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null +comments: + type: array + items: + type: string + example: [] +connector: + type: object + properties: + $ref: 'connector_properties.yaml' +created_at: + type: string + format: date-time + example: "2022-05-13T09:16:17.416Z" +created_by: + type: object + properties: + email: + type: string + example: "ahunley@imf.usa.gov" + full_name: + type: string + example: "Alan Hunley" + username: + type: string + example: "ahunley" +description: + type: string + example: "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: + type: integer + description: The elapsed time from the creation of the case to its closure (in seconds). If the case has not been closed, the duration is set to null. + example: 120 +external_service: + type: object + properties: + connector_id: + type: string + connector_name: + type: string + external_id: + type: string + external_title: + type: string + external_url: + type: string + pushed_at: + type: string + format: date-time + pushed_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null +id: + type: string + example: "66b9aa00-94fa-11ea-9f74-e7e108796192" +owner: + $ref: 'owners.yaml' +settings: + type: object + properties: + syncAlerts: + type: boolean + example: true +status: + $ref: 'status.yaml' +tags: + type: array + items: + type: string + example: ["phishing","social engineering","bubblegum"] +title: + type: string + example: "This case will self-destruct in 5 seconds" +totalAlerts: + type: integer + example: 0 +totalComment: + type: integer + example: 0 +updated_at: + type: string + format: date-time + nullable: true + example: null +updated_by: + type: object + properties: + email: + type: string + full_name: + type: string + username: + type: string + nullable: true + example: null +version: + type: string + example: "WzUzMiwxXQ==" diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/closure_types.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/closure_types.yaml new file mode 100644 index 000000000000000..f09063d0db18f7d --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/closure_types.yaml @@ -0,0 +1,5 @@ +type: string +description: Indicates whether a case is automatically closed when it is pushed to external systems (`close-by-pushing`) or not automatically closed (`close-by-user`). +enum: + - close-by-pushing + - close-by-user \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/comment_types.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/comment_types.yaml new file mode 100644 index 000000000000000..a6a86ae163b2083 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/comment_types.yaml @@ -0,0 +1,5 @@ +type: string +description: The type of comment. +enum: + - alert + - user \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/connector_properties.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/connector_properties.yaml new file mode 100644 index 000000000000000..c2bc2ab7c887aba --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/connector_properties.yaml @@ -0,0 +1,65 @@ +fields: + description: An object containing the connector fields. To create a case without a connector, specify null. If you want to omit any individual field, specify null as its value. + nullable: true + type: object + properties: + caseId: + description: The case identifier for Swimlane connectors. + type: string + category: + description: The category of the incident for ServiceNow ITSM and ServiceNow SecOps connectors. + type: string + destIp: + description: A comma-separated list of destination IPs for ServiceNow SecOps connectors. + type: string + impact: + description: The effect an incident had on business for ServiceNow ITSM connectors. + type: string + issueType: + description: The type of issue for Jira connectors. + type: string + issueTypes: + description: The type of incident for IBM Resilient connectors. + type: array + items: + type: number + malwareHash: + description: A comma-separated list of malware hashes for ServiceNow SecOps connectors. + type: string + malwareUrl: + description: A comma-separated list of malware URLs for ServiceNow SecOps connectors. + type: string + parent: + description: The key of the parent issue, when the issue type is sub-task for Jira connectors. + type: string + priority: + description: The priority of the issue for Jira and ServiceNow SecOps connectors. + type: string + severity: + description: The severity of the incident for ServiceNow ITSM connectors. + type: string + severityCode: + description: The severity code of the incident for IBM Resilient connectors. + type: number + sourceIp: + description: A comma-separated list of source IPs for ServiceNow SecOps connectors. + type: string + subcategory: + description: The subcategory of the incident for ServiceNow ITSM connectors. + type: string + urgency: + description: The extent to which the incident resolution can be delayed for ServiceNow ITSM connectors. + type: string + required: + - fields + - id + - name + - type +id: + description: The identifier for the connector. To create a case without a connector, use `none`. + type: string +name: + description: The name of the connector. To create a case without a connector, use `none`. + type: string +type: + $ref: 'connector_types.yaml' \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/connector_types.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/connector_types.yaml new file mode 100644 index 000000000000000..24c1ec58808289d --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/connector_types.yaml @@ -0,0 +1,9 @@ +type: string +description: The type of connector. +enum: + - .jira + - .none + - .resilient + - .servicenow + - .servicenow-sir + - .swimlane \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/owners.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/owners.yaml new file mode 100644 index 000000000000000..f39324a36e7028b --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/owners.yaml @@ -0,0 +1,6 @@ +type: string +description: Owner apps +enum: + - cases + - observability + - securitySolution \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/components/schemas/status.yaml b/x-pack/plugins/cases/docs/openapi/components/schemas/status.yaml new file mode 100644 index 000000000000000..1fe2e342dd7765f --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/components/schemas/status.yaml @@ -0,0 +1,6 @@ +type: string +description: The status of the case. +enum: + - closed + - in-progress + - open \ No newline at end of file diff --git a/x-pack/plugins/cases/docs/openapi/entrypoint.yaml b/x-pack/plugins/cases/docs/openapi/entrypoint.yaml new file mode 100644 index 000000000000000..14155c156b0cca4 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/entrypoint.yaml @@ -0,0 +1,92 @@ +openapi: 3.0.1 +info: + title: Cases + description: OpenAPI schema for Cases endpoints + version: '0.1' + contact: + name: Cases Team + license: + name: Elastic License 2.0 + url: https://www.elastic.co/licensing/elastic-license +tags: + - name: cases + description: Case APIs enable you to open and track issues. + - name: kibana + description: Kibana APIs enable you to interact with Kibana features. +servers: + - url: 'http://localhost:5601' + description: local +paths: + /api/cases: + $ref: paths/api@cases.yaml +# /api/cases/_find: +# $ref: paths/api@cases@_find.yaml +# '/api/cases/alerts/{alertId}': +# $ref: 'paths/api@cases@alerts@{alertid}.yaml' +# '/api/cases/configure': +# $ref: paths/api@cases@configure.yaml +# '/api/cases/configure/{configurationId}': +# $ref: paths/api@cases@configure@{configurationid}.yaml +# '/api/cases/configure/connectors/_find': +# $ref: paths/api@cases@configure@connectors@_find.yaml +# '/api/cases/reporters': +# $ref: 'paths/api@cases@reporters.yaml' +# '/api/cases/status': +# $ref: 'paths/api@cases@status.yaml' +# '/api/cases/tags': +# $ref: 'paths/api@cases@tags.yaml' +# '/api/cases/{caseId}': +# $ref: 'paths/api@cases@{caseid}.yaml' +# '/api/cases/{caseId}/alerts': +# $ref: 'paths/api@cases@{caseid}@alerts.yaml' +# '/api/cases/{caseId}/comments': +# $ref: 'paths/api@cases@{caseid}@comments.yaml' +# '/api/cases/{caseId}/comments/{commentId}': +# $ref: 'paths/api@cases@{caseid}@comments@{commentid}.yaml' +# '/api/cases/{caseId}/connector/{connectorId}/_push': +# $ref: 'paths/api@cases@{caseid}@connector@{connectorid}@_push.yaml' +# '/api/cases/{caseId}/user_actions': +# $ref: 'paths/api@cases@{caseid}@user_actions.yaml' + + '/s/{spaceId}/api/cases': + $ref: 'paths/s@{spaceid}@api@cases.yaml' + # '/s/{spaceId}/api/cases/_find': + # $ref: 'paths/s@{spaceid}@api@cases@_find.yaml' + # '/s/{spaceId}/api/cases/alerts/{alertId}': + # $ref: 'paths/s@{spaceid}@api@cases@alerts@{alertid}.yaml' + # '/s/{spaceId}/api/cases/configure': + # $ref: paths/s@{spaceid}@api@cases@configure.yaml + # '/s/{spaceId}/api/cases/configure/{configurationId}': + # $ref: paths/s@{spaceid}@api@cases@configure@{configurationid}.yaml + # '/s/{spaceId}/api/cases/configure/connectors/_find': + # $ref: paths/s@{spaceid}@api@cases@configure@connectors@_find.yaml + # '/s/{spaceId}/api/cases/reporters': + # $ref: 'paths/s@{spaceid}@api@cases@reporters.yaml' + # '/s/{spaceId}/api/cases/status': + # $ref: 'paths/s@{spaceid}@api@cases@status.yaml' + # '/s/{spaceId}/api/cases/tags': + # $ref: 'paths/s@{spaceid}@api@cases@tags.yaml' + # '/s/{spaceId}/api/cases/{caseId}': + # $ref: 'paths/s@{spaceid}@api@cases@{caseid}.yaml' + # '/s/{spaceId}/api/cases/{caseId}/alerts': + # $ref: 'paths/s@{spaceid}@api@cases@{caseid}@alerts.yaml' + # '/s/{spaceId}/api/cases/{caseId}/comments': + # $ref: 'paths/s@{spaceid}@api@cases@{caseid}@comments.yaml' + # '/s/{spaceId}/api/cases/{caseId}/comments/{commentId}': + # $ref: 'paths/s@{spaceid}@api@cases@{caseid}@comments@{commentid}.yaml' + # '/s/{spaceId}/api/cases/{caseId}/connector/{connectorId}/_push': + # $ref: 'paths/s@{spaceid}@api@cases@{caseid}@connector@{connectorid}@_push.yaml' + # '/s/{spaceId}/api/cases/{caseId}/user_actions': + # $ref: 'paths/s@{spaceid}@api@cases@{caseid}@user_actions.yaml' +components: + securitySchemes: + basicAuth: + type: http + scheme: basic + apiKeyAuth: + type: apiKey + in: header + name: ApiKey +security: + - basicAuth: [] + - apiKeyAuth: [] diff --git a/x-pack/plugins/cases/docs/openapi/paths/README.md b/x-pack/plugins/cases/docs/openapi/paths/README.md new file mode 100644 index 000000000000000..b7818c8474fc8af --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/paths/README.md @@ -0,0 +1,10 @@ +Paths +===== + +Each path definition for which there is a specification exists within this folder. + +These files currently use the following conventions: + +* path separator token (e.g. `@`) is included in the file name +* path parameter (e.g. `{example}`) is included in the file name +* there is one file per path; each file can contain multiple operations diff --git a/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml b/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml new file mode 100644 index 000000000000000..c37bb3ecef6457d --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/paths/api@cases.yaml @@ -0,0 +1,161 @@ +post: + description: > + Creates a case. + You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're creating. + tags: + - cases + - kibana + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml + requestBody: + content: + application/json: + schema: + type: object + properties: + connector: + description: An object that contains the connector configuration. + type: object + properties: + $ref: '../components/schemas/connector_properties.yaml' + description: + description: The description for the case. + type: string + owner: + $ref: '../components/schemas/owners.yaml' + settings: + description: An object that contains the case settings. + type: object + properties: + syncAlerts: + description: Turns alert syncing on or off. + type: boolean + tags: + description: The words and phrases that help categorize cases. It can be an empty array. + type: array + items: + type: string + title: + description: A title for the case. + type: string + required: + - connector + - description + - owner + - settings + - tags + - title + examples: + createCaseRequest: + $ref: '../components/examples/create_case_request.yaml' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + type: object + properties: + $ref: '../components/schemas/case_response_properties.yaml' + examples: + createCaseResponse: + $ref: '../components/examples/create_case_response.yaml' + servers: + - url: https://localhost:5601 + +delete: + description: > + Deletes one or more cases. + You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the cases you're deleting. + tags: + - cases + - kibana + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml + - name: ids + description: The cases that you want to removed. To retrieve case IDs, use the find cases API. All non-ASCII characters must be URL encoded. + in: query + required: true + schema: + type: string + example: 'd4e7abb0-b462-11ec-9a8d-698504725a43' + responses: + '204': + description: Indicates a successful call. + servers: + - url: https://localhost:5601 + +patch: + description: > + Updates one or more cases. + You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're updating. + tags: + - cases + - kibana + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml + requestBody: + content: + application/json: + schema: + type: object + properties: + cases: + type: array + items: + type: object + properties: + connector: + description: An object that contains the connector configuration. + type: object + properties: + $ref: '../components/schemas/connector_properties.yaml' + description: + description: The description for the case. + type: string + id: + description: The identifier for the case. + type: string + settings: + description: An object that contains the case settings. + type: object + properties: + syncAlerts: + description: Turns alert syncing on or off. + type: boolean + status: + $ref: '../components/schemas/status.yaml' + tags: + description: The words and phrases that help categorize cases. + type: array + items: + type: string + title: + description: A title for the case. + type: string + version: + description: The current version of the case. + type: string + required: + - id + - version + examples: + updateCaseRequest: + $ref: '../components/examples/update_case_request.yaml' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + type: object + properties: + $ref: '../components/schemas/case_response_properties.yaml' + examples: + updateCaseResponse: + $ref: '../components/examples/update_case_response.yaml' + servers: + - url: https://localhost:5601 + +servers: + - url: https://localhost:5601 \ No newline at end of file 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 new file mode 100644 index 000000000000000..c03ea64a5353843 --- /dev/null +++ b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases.yaml @@ -0,0 +1,164 @@ +post: + description: > + Creates a case. + You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're creating. + tags: + - cases + - kibana + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml + - $ref: '../components/parameters/space_id.yaml' + requestBody: + content: + application/json: + schema: + type: object + properties: + connector: + description: An object that contains the connector configuration. + type: object + properties: + $ref: '../components/schemas/connector_properties.yaml' + description: + description: The description for the case. + type: string + owner: + $ref: '../components/schemas/owners.yaml' + settings: + description: An object that contains the case settings. + type: object + properties: + syncAlerts: + description: Turns alert syncing on or off. + type: boolean + tags: + description: The words and phrases that help categorize cases. It can be an empty array. + type: array + items: + type: string + title: + description: A title for the case. + type: string + required: + - connector + - description + - owner + - settings + - tags + - title + examples: + createCaseRequest: + $ref: '../components/examples/create_case_request.yaml' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + type: object + properties: + $ref: '../components/schemas/case_response_properties.yaml' + examples: + createCaseResponse: + $ref: '../components/examples/create_case_response.yaml' + servers: + - url: https://localhost:5601 + +delete: + description: > + Deletes one or more cases. + You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the cases you're deleting. + tags: + - cases + - kibana + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml + - $ref: '../components/parameters/space_id.yaml' + - name: ids + description: The cases that you want to removed. All non-ASCII characters must be URL encoded. + in: query + required: true + schema: + type: string + example: 'd4e7abb0-b462-11ec-9a8d-698504725a43' + responses: + '204': + description: Indicates a successful call. + servers: + - url: https://localhost:5601 + +patch: + description: > + Updates one or more cases. + You must have all privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're updating. + tags: + - cases + - kibana + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml + - $ref: '../components/parameters/space_id.yaml' + requestBody: + content: + application/json: + schema: + type: object + properties: + cases: + type: array + items: + type: object + properties: + connector: + description: An object that contains the connector configuration. + type: object + properties: + $ref: '../components/schemas/connector_properties.yaml' + description: + description: The description for the case. + type: string + id: + description: The identifier for the case. + type: string + settings: + description: An object that contains the case settings. + type: object + properties: + syncAlerts: + description: Turns alert syncing on or off. + type: boolean + status: + $ref: '../components/schemas/status.yaml' + tags: + description: The words and phrases that help categorize cases. + type: array + items: + type: string + title: + description: A title for the case. + type: string + version: + description: The current version of the case. + type: string + required: + - id + - version + examples: + updateCaseRequest: + $ref: '../components/examples/update_case_request.yaml' + responses: + '200': + description: Indicates a successful call. + content: + application/json; charset=utf-8: + schema: + type: object + properties: + $ref: '../components/schemas/case_response_properties.yaml' + examples: + updateCaseResponse: + $ref: '../components/examples/update_case_response.yaml' + servers: + - url: https://localhost:5601 + +servers: + - url: https://localhost:5601 \ No newline at end of file diff --git a/x-pack/plugins/cases/public/api/__mocks__/index.ts b/x-pack/plugins/cases/public/api/__mocks__/index.ts index a1df651240224d7..1632394338a47f3 100644 --- a/x-pack/plugins/cases/public/api/__mocks__/index.ts +++ b/x-pack/plugins/cases/public/api/__mocks__/index.ts @@ -5,13 +5,20 @@ * 2.0. */ -import { CasesFindRequest } from '../../../common/api'; +import { CasesFindRequest, CasesMetricsRequest } from '../../../common/api'; import { HTTPService } from '..'; -import { casesStatus } from '../../containers/mock'; -import { CasesStatus } from '../../containers/types'; +import { casesMetrics, casesStatus } from '../../containers/mock'; +import { CasesMetrics, CasesStatus } from '../../containers/types'; export const getCasesStatus = async ({ http, signal, query, }: HTTPService & { query: CasesFindRequest }): Promise => Promise.resolve(casesStatus); + +export const getCasesMetrics = async ({ + http, + signal, + query, +}: HTTPService & { query: CasesMetricsRequest }): Promise => + Promise.resolve(casesMetrics); diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts index 10005b2c87bce1b..908a0dd5d52df3b 100644 --- a/x-pack/plugins/cases/public/common/translations.ts +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -148,7 +148,7 @@ export const TAGS_HELP = i18n.translate('xpack.cases.createCase.fieldTagsHelpTex }); export const TAGS_EMPTY_ERROR = i18n.translate('xpack.cases.createCase.fieldTagsEmptyError', { - defaultMessage: 'A tag must contain at least one non-space character', + defaultMessage: 'A tag must contain at least one non-space character.', }); export const NO_TAGS = i18n.translate('xpack.cases.caseView.noTags', { @@ -198,6 +198,10 @@ export const MARKED_CASE_AS = i18n.translate('xpack.cases.caseView.markedCaseAs' defaultMessage: 'marked case as', }); +export const SET_SEVERITY_TO = i18n.translate('xpack.cases.caseView.setSeverityTo', { + defaultMessage: 'set severity to', +}); + export const OPEN_CASES = i18n.translate('xpack.cases.caseTable.openCases', { defaultMessage: 'Open cases', }); @@ -225,8 +229,7 @@ export const SYNC_ALERTS_SWITCH_LABEL_OFF = i18n.translate( ); export const SYNC_ALERTS_HELP = i18n.translate('xpack.cases.components.create.syncAlertHelpText', { - defaultMessage: - 'Enabling this option will sync the status of alerts in this case with the case status.', + defaultMessage: 'Enabling this option will sync the alert statuses with the case status.', }); export const ALERT = i18n.translate('xpack.cases.common.alertLabel', { @@ -264,18 +267,18 @@ export const CASE_SUCCESS_TOAST = (title: string) => export const CASE_ALERT_SUCCESS_TOAST = (title: string) => i18n.translate('xpack.cases.actions.caseAlertSuccessToast', { values: { title }, - defaultMessage: 'An alert has been added to "{title}"', + defaultMessage: 'An alert was added to "{title}"', }); export const CASE_ALERT_SUCCESS_SYNC_TEXT = i18n.translate( 'xpack.cases.actions.caseAlertSuccessSyncText', { - defaultMessage: 'Alerts in this case have their status synched with the case status', + defaultMessage: 'The alert statuses are synched with the case status.', } ); export const VIEW_CASE = i18n.translate('xpack.cases.actions.viewCase', { - defaultMessage: 'View Case', + defaultMessage: 'View case', }); export const APP_TITLE = i18n.translate('xpack.cases.common.appTitle', { 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 4dfbe6495364db5..e788f7b399bb4af 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 @@ -83,7 +83,7 @@ describe('Use cases toast hook', () => { theCase: mockCase, attachments: [alertComment as SupportedCaseAttachment], }); - validateTitle('An alert has been added to "Another horrible breach!!'); + validateTitle('An alert was added to "Another horrible breach!!'); }); it('should display a generic title when called with a non-alert attachament', () => { @@ -130,7 +130,7 @@ describe('Use cases toast hook', () => { theCase: mockCase, attachments: [alertComment as SupportedCaseAttachment], }); - validateContent('Alerts in this case have their status synched with the case status'); + validateContent('The alert statuses are synched with the case status.'); }); it('renders empty content when called with an alert attachment and sync off', () => { @@ -144,7 +144,7 @@ describe('Use cases toast hook', () => { theCase: { ...mockCase, settings: { ...mockCase.settings, syncAlerts: false } }, attachments: [alertComment as SupportedCaseAttachment], }); - validateContent('View Case'); + validateContent('View case'); }); it('renders a correct successful message content', () => { @@ -152,7 +152,7 @@ describe('Use cases toast hook', () => { ); expect(result.getByTestId('toaster-content-sync-text')).toHaveTextContent('my content'); - expect(result.getByTestId('toaster-content-case-view-link')).toHaveTextContent('View Case'); + expect(result.getByTestId('toaster-content-case-view-link')).toHaveTextContent('View case'); expect(onViewCaseClick).not.toHaveBeenCalled(); }); @@ -161,7 +161,7 @@ describe('Use cases toast hook', () => { ); expect(result.queryByTestId('toaster-content-sync-text')).toBeFalsy(); - expect(result.getByTestId('toaster-content-case-view-link')).toHaveTextContent('View Case'); + expect(result.getByTestId('toaster-content-case-view-link')).toHaveTextContent('View case'); expect(onViewCaseClick).not.toHaveBeenCalled(); }); 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 f0a3502fd6813fa..853a32eaabbafb5 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 @@ -36,12 +36,14 @@ import { waitForComponentToUpdate } from '../../common/test_utils'; import { useCreateAttachments } from '../../containers/use_create_attachments'; import { useGetTags } from '../../containers/use_get_tags'; import { useGetReporters } from '../../containers/use_get_reporters'; +import { useGetCasesMetrics } from '../../containers/use_get_cases_metrics'; jest.mock('../../containers/use_create_attachments'); jest.mock('../../containers/use_bulk_update_case'); jest.mock('../../containers/use_delete_cases'); jest.mock('../../containers/use_get_cases'); jest.mock('../../containers/use_get_cases_status'); +jest.mock('../../containers/use_get_cases_metrics'); jest.mock('../../containers/use_get_action_license'); jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/use_get_reporters'); @@ -55,6 +57,7 @@ jest.mock('../app/use_available_owners', () => ({ const useDeleteCasesMock = useDeleteCases as jest.Mock; const useGetCasesMock = useGetCases as jest.Mock; const useGetCasesStatusMock = useGetCasesStatus as jest.Mock; +const useGetCasesMetricsMock = useGetCasesMetrics as jest.Mock; const useUpdateCasesMock = useUpdateCases as jest.Mock; const useGetActionLicenseMock = useGetActionLicense as jest.Mock; const useGetTagsMock = useGetTags as jest.Mock; @@ -118,6 +121,12 @@ describe('AllCasesListGeneric', () => { isLoading: false, }; + const defaultCasesMetrics = { + mttr: 5, + isLoading: false, + fetchCasesMetrics: jest.fn(), + }; + const defaultUpdateCases = { isUpdated: false, isLoading: false, @@ -157,6 +166,7 @@ describe('AllCasesListGeneric', () => { useGetCasesMock.mockReturnValue(defaultGetCases); useDeleteCasesMock.mockReturnValue(defaultDeleteCases); useGetCasesStatusMock.mockReturnValue(defaultCasesStatus); + useGetCasesMetricsMock.mockReturnValue(defaultCasesMetrics); useGetActionLicenseMock.mockReturnValue(defaultActionLicense); useGetTagsMock.mockReturnValue({ tags: ['coke', 'pepsi'], fetchTags: jest.fn() }); useGetReportersMock.mockReturnValue({ @@ -204,12 +214,35 @@ describe('AllCasesListGeneric', () => { .childAt(0) .prop('value') ).toBe(useGetCasesMockState.data.cases[0].createdAt); + + expect( + wrapper.find(`[data-test-subj="case-table-column-severity"]`).first().text().toLowerCase() + ).toBe(useGetCasesMockState.data.cases[0].severity); + expect(wrapper.find(`[data-test-subj="case-table-case-count"]`).first().text()).toEqual( 'Showing 10 cases' ); }); }); + it('should show a tooltip with all tags when hovered', async () => { + useGetCasesMock.mockReturnValue({ + ...defaultGetCases, + filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open }, + }); + const result = render( + + + + ); + + userEvent.hover(result.queryAllByTestId('case-table-column-tags')[0]); + + await waitFor(() => { + expect(result.getByTestId('case-table-column-tags-tooltip')).toBeTruthy(); + }); + }); + it('should render empty fields', async () => { useGetCasesMock.mockReturnValue({ ...defaultGetCases, @@ -223,6 +256,7 @@ describe('AllCasesListGeneric', () => { createdAt: null, createdBy: null, status: null, + severity: null, tags: null, title: null, totalComment: null, @@ -318,6 +352,15 @@ describe('AllCasesListGeneric', () => { }); }); + it('should render the case stats', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="cases-count-stats"]')).toBeTruthy(); + }); + it.skip('Bulk delete', async () => { useGetCasesMock.mockReturnValue({ ...defaultGetCases, diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx index 5eac485e24c7b85..86933b1395b3891 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx @@ -27,6 +27,7 @@ import { EuiBasicTableOnChange } from './types'; import { CasesTable } from './table'; import { useConnectors } from '../../containers/configure/use_connectors'; import { useCasesContext } from '../cases_context/use_cases_context'; +import { CasesMetrics } from './cases_metrics'; const ProgressLoader = styled(EuiProgress)` ${({ $isShow }: { $isShow: boolean }) => @@ -56,6 +57,7 @@ export const AllCasesList = React.memo( const { owner, userCanCrud } = useCasesContext(); const hasOwner = !!owner.length; const availableSolutions = useAvailableCasesOwners(); + const [refresh, setRefresh] = useState(0); const firstAvailableStatus = head(difference(caseStatuses, hiddenStatuses)); const initialFilterOptions = { @@ -104,8 +106,13 @@ export const AllCasesList = React.memo( const refreshCases = useCallback( (dataRefresh = true) => { deselectCases(); - if (dataRefresh) refetchCases(); - if (doRefresh) doRefresh(); + if (dataRefresh) { + refetchCases(); + setRefresh((currRefresh: number) => currRefresh + 1); + } + if (doRefresh) { + doRefresh(); + } if (filterRefetch.current != null) { filterRefetch.current(); } @@ -206,6 +213,7 @@ export const AllCasesList = React.memo( className="essentialAnimation" $isShow={(isCasesLoading || isLoading) && !isDataEmpty} /> + ( tags: filterOptions.tags, status: filterOptions.status, owner: filterOptions.owner, + severity: filterOptions.severity, }} setFilterRefetch={setFilterRefetch} hiddenStatuses={hiddenStatuses} diff --git a/x-pack/plugins/cases/public/components/all_cases/cases_metrics.test.tsx b/x-pack/plugins/cases/public/components/all_cases/cases_metrics.test.tsx new file mode 100644 index 000000000000000..6141527d59f4252 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/cases_metrics.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { within } from '@testing-library/dom'; +import React from 'react'; +import { AppMockRenderer, createAppMockRenderer } from '../../common/mock'; +import { useGetCasesMetrics } from '../../containers/use_get_cases_metrics'; +import { useGetCasesStatus } from '../../containers/use_get_cases_status'; +import { CasesMetrics } from './cases_metrics'; + +jest.mock('../../containers/use_get_cases_metrics'); +jest.mock('../../containers/use_get_cases_status'); + +const useGetCasesMetricsMock = useGetCasesMetrics as jest.Mock; +const useGetCasesStatusMock = useGetCasesStatus as jest.Mock; + +describe('Cases metrics', () => { + useGetCasesStatusMock.mockReturnValue({ + countOpenCases: 2, + countInProgressCases: 3, + countClosedCases: 4, + isLoading: false, + fetchCasesStatus: jest.fn(), + }); + useGetCasesMetricsMock.mockReturnValue({ + // 600 seconds = 10m + mttr: 600, + isLoading: false, + fetchCasesMetrics: jest.fn(), + }); + + let appMockRenderer: AppMockRenderer; + + beforeEach(() => { + appMockRenderer = createAppMockRenderer(); + }); + + it('renders the correct stats', () => { + const result = appMockRenderer.render(); + expect(result.getByTestId('cases-metrics-stats')).toBeTruthy(); + expect(within(result.getByTestId('openStatsHeader')).getByText(2)).toBeTruthy(); + expect(within(result.getByTestId('inProgressStatsHeader')).getByText(3)).toBeTruthy(); + expect(within(result.getByTestId('closedStatsHeader')).getByText(4)).toBeTruthy(); + expect(within(result.getByTestId('mttrStatsHeader')).getByText('10m')).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/cases_metrics.tsx b/x-pack/plugins/cases/public/components/all_cases/cases_metrics.tsx new file mode 100644 index 000000000000000..3325b6de4ebcc6a --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/cases_metrics.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FunctionComponent, useEffect, useMemo } from 'react'; +import { + EuiDescriptionList, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLoadingSpinner, + EuiToolTip, +} from '@elastic/eui'; +import styled, { css } from 'styled-components'; +import prettyMilliseconds from 'pretty-ms'; +import { CaseStatuses } from '../../../common/api'; +import { useGetCasesStatus } from '../../containers/use_get_cases_status'; +import { StatusStats } from '../status/status_stats'; +import { useGetCasesMetrics } from '../../containers/use_get_cases_metrics'; +import { ATTC_DESCRIPTION, ATTC_STAT } from './translations'; + +interface CountProps { + refresh?: number; +} +const MetricsFlexGroup = styled.div` + ${({ theme }) => css` + .euiFlexGroup { + border: ${theme.eui.euiBorderThin}; + border-radius: ${theme.eui.euiBorderRadius}; + margin: 0 0 ${theme.eui.euiSizeL} 0; + } + @media only screen and (max-width: ${theme.eui.euiBreakpoints.s}) { + .euiFlexGroup { + padding: ${theme.eui.euiSizeM}; + } + } + `} +`; + +export const CasesMetrics: FunctionComponent = ({ refresh }) => { + const { + countOpenCases, + countInProgressCases, + countClosedCases, + isLoading: isCasesStatusLoading, + fetchCasesStatus, + } = useGetCasesStatus(); + + const { mttr, isLoading: isCasesMetricsLoading, fetchCasesMetrics } = useGetCasesMetrics(); + + const mttrValue = useMemo( + () => (mttr ? prettyMilliseconds(mttr * 1000, { compact: true, verbose: false }) : '-'), + [mttr] + ); + + useEffect(() => { + if (refresh != null) { + fetchCasesStatus(); + fetchCasesMetrics(); + } + }, [fetchCasesMetrics, fetchCasesStatus, refresh]); + + return ( + + + + + + + + + + + + + + <> + {ATTC_STAT} + + + ), + description: isCasesMetricsLoading ? ( + + ) : ( + mttrValue + ), + }, + ]} + /> + + + + ); +}; +CasesMetrics.displayName = 'CasesMetrics'; 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 543e6ef6f4871e2..c895dfdc11f3f89 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.tsx @@ -18,12 +18,14 @@ import { EuiFlexGroup, EuiFlexItem, EuiIcon, + EuiHealth, + EuiToolTip, } from '@elastic/eui'; import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import styled from 'styled-components'; import { Case, DeleteCase } from '../../../common/ui/types'; -import { CaseStatuses, ActionConnector } from '../../../common/api'; +import { CaseStatuses, ActionConnector, CaseSeverity } from '../../../common/api'; import { OWNER_INFO } from '../../../common/constants'; import { getEmptyTagValue } from '../empty_value'; import { FormattedRelativePreferenceDate } from '../formatted_date'; @@ -40,6 +42,7 @@ import { TruncatedText } from '../truncated_text'; import { getConnectorIcon } from '../utils'; import type { CasesOwners } from '../../client/helpers/can_use_cases'; import { useCasesFeatures } from '../cases_context/use_cases_features'; +import { severities } from '../severity/config'; export type CasesColumns = | EuiTableActionsColumnType @@ -54,10 +57,6 @@ const Spacer = styled.span` margin-left: ${({ theme }) => theme.eui.paddingSizes.s}; `; -const TagWrapper = styled(EuiBadgeGroup)` - width: 100%; -`; - const renderStringField = (field: string, dataTestSubj: string) => field != null ? {field} : getEmptyTagValue(); @@ -203,8 +202,8 @@ export const useCasesColumns = ({ name: i18n.TAGS, render: (tags: Case['tags']) => { if (tags != null && tags.length > 0) { - return ( - + const badges = ( + {tags.map((tag: string, i: number) => ( ))} - + + ); + + return ( + + {badges} + ); } return getEmptyTagValue(); @@ -300,30 +309,6 @@ export const useCasesColumns = ({ return getEmptyTagValue(); }, }, - ...(isSelectorView - ? [ - { - align: RIGHT_ALIGNMENT, - render: (theCase: Case) => { - if (theCase.id != null) { - return ( - { - assignCaseAction(theCase); - }} - size="s" - fill={true} - > - {i18n.SELECT} - - ); - } - return getEmptyTagValue(); - }, - }, - ] - : []), ...(!isSelectorView ? [ { @@ -351,6 +336,45 @@ export const useCasesColumns = ({ }, ] : []), + { + name: i18n.SEVERITY, + render: (theCase: Case) => { + if (theCase.severity != null) { + const severityData = severities[theCase.severity ?? CaseSeverity.LOW]; + return ( + + {severityData.label} + + ); + } + return getEmptyTagValue(); + }, + }, + + ...(isSelectorView + ? [ + { + align: RIGHT_ALIGNMENT, + render: (theCase: Case) => { + if (theCase.id != null) { + return ( + { + assignCaseAction(theCase); + }} + size="s" + fill={true} + > + {i18n.SELECT} + + ); + } + return getEmptyTagValue(); + }, + }, + ] + : []), ...(userCanCrud && !isSelectorView ? [ { diff --git a/x-pack/plugins/cases/public/components/all_cases/count.tsx b/x-pack/plugins/cases/public/components/all_cases/count.tsx deleted file mode 100644 index cd4abdd08b8e706..000000000000000 --- a/x-pack/plugins/cases/public/components/all_cases/count.tsx +++ /dev/null @@ -1,59 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { FunctionComponent, useEffect } from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { CaseStatuses } from '../../../common/api'; -import { Stats } from '../status'; -import { useGetCasesStatus } from '../../containers/use_get_cases_status'; - -interface CountProps { - refresh?: number; -} -export const Count: FunctionComponent = ({ refresh }) => { - const { - countOpenCases, - countInProgressCases, - countClosedCases, - isLoading: isCasesStatusLoading, - fetchCasesStatus, - } = useGetCasesStatus(); - useEffect(() => { - if (refresh != null) { - fetchCasesStatus(); - } - }, [fetchCasesStatus, refresh]); - return ( - - - - - - - - - - - - ); -}; -Count.displayName = 'Count'; diff --git a/x-pack/plugins/cases/public/components/all_cases/header.tsx b/x-pack/plugins/cases/public/components/all_cases/header.tsx index 4e66083711e2b73..9a02594a790fa77 100644 --- a/x-pack/plugins/cases/public/components/all_cases/header.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/header.tsx @@ -7,60 +7,26 @@ import React, { FunctionComponent } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import styled, { css } from 'styled-components'; import { HeaderPage } from '../header_page'; import * as i18n from './translations'; -import { Count } from './count'; import { ErrorMessage } from '../use_push_to_service/callout/types'; import { NavButtons } from './nav_buttons'; interface OwnProps { actionsErrors: ErrorMessage[]; - refresh: number; userCanCrud: boolean; } type Props = OwnProps; -const FlexItemDivider = styled(EuiFlexItem)` - ${({ theme }) => css` - .euiFlexGroup--gutterMedium > &.euiFlexItem { - border-right: ${theme.eui.euiBorderThin}; - padding-right: ${theme.eui.euiSize}; - margin-right: ${theme.eui.euiSize}; - @media only screen and (max-width: ${theme.eui.euiBreakpoints.l}) { - padding-right: 0; - border-right: 0; - margin-right: 0; - } - } - `} -`; - -export const CasesTableHeader: FunctionComponent = ({ - actionsErrors, - refresh, - userCanCrud, -}) => ( +export const CasesTableHeader: FunctionComponent = ({ actionsErrors, userCanCrud }) => ( {userCanCrud ? ( - <> - - - - - - - - - ) : ( - // doesn't include the horizontal bar that divides the buttons and other padding since we don't have any buttons - // to the right - + - )} + ) : null} ); diff --git a/x-pack/plugins/cases/public/components/all_cases/index.tsx b/x-pack/plugins/cases/public/components/all_cases/index.tsx index 8ea7681eb44d948..c2811df9a684d75 100644 --- a/x-pack/plugins/cases/public/components/all_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useState, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { CasesDeepLinkId } from '../../common/navigation'; import { useGetActionLicense } from '../../containers/use_get_action_license'; import { useCasesContext } from '../cases_context/use_cases_context'; @@ -16,20 +16,15 @@ import { CasesTableHeader } from './header'; export const AllCases: React.FC = () => { const { userCanCrud } = useCasesContext(); - const [refresh, setRefresh] = useState(0); useCasesBreadcrumbs(CasesDeepLinkId.cases); - const doRefresh = useCallback(() => { - setRefresh((prev) => prev + 1); - }, [setRefresh]); - const { actionLicense } = useGetActionLicense(); const actionsErrors = useMemo(() => getActionLicenseError(actionLicense), [actionLicense]); return ( <> - - + + ); }; diff --git a/x-pack/plugins/cases/public/components/all_cases/severity_filter.test.tsx b/x-pack/plugins/cases/public/components/all_cases/severity_filter.test.tsx new file mode 100644 index 000000000000000..7366bb3fceebb67 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/severity_filter.test.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CaseSeverity } from '../../../common/api'; +import React from 'react'; +import { AppMockRenderer, createAppMockRenderer } from '../../common/mock'; +import userEvent from '@testing-library/user-event'; +import { waitFor } from '@testing-library/dom'; +import { SeverityFilter } from './severity_filter'; + +describe('Severity form field', () => { + const onSeverityChange = jest.fn(); + let appMockRender: AppMockRenderer; + const props = { + isLoading: false, + selectedSeverity: CaseSeverity.LOW, + isDisabled: false, + onSeverityChange, + }; + beforeEach(() => { + appMockRender = createAppMockRenderer(); + }); + it('renders', () => { + const result = appMockRender.render(); + expect(result.getByTestId('case-severity-filter')).not.toHaveAttribute('disabled'); + }); + + // default to LOW in this test configuration + it('defaults to the correct value', () => { + const result = appMockRender.render(); + // two items. one for the popover one for the selected field + expect(result.getAllByTestId('case-severity-filter-low').length).toBe(2); + }); + + it('selects the correct value when changed', async () => { + const result = appMockRender.render(); + userEvent.click(result.getByTestId('case-severity-filter')); + userEvent.click(result.getByTestId('case-severity-filter-high')); + await waitFor(() => { + expect(onSeverityChange).toHaveBeenCalledWith('high'); + }); + }); + + it('selects the correct value when changed (all)', async () => { + const result = appMockRender.render(); + userEvent.click(result.getByTestId('case-severity-filter')); + userEvent.click(result.getByTestId('case-severity-filter-all')); + await waitFor(() => { + expect(onSeverityChange).toHaveBeenCalledWith('all'); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/severity_filter.tsx b/x-pack/plugins/cases/public/components/all_cases/severity_filter.tsx new file mode 100644 index 000000000000000..a9f4a6565c318b0 --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/severity_filter.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiFlexGroup, + EuiFlexItem, + EuiHealth, + EuiSuperSelect, + EuiSuperSelectOption, + EuiText, +} from '@elastic/eui'; +import React from 'react'; +import { CaseSeverityWithAll, SeverityAll } from '../../containers/types'; +import { severitiesWithAll } from '../severity/config'; + +interface Props { + selectedSeverity: CaseSeverityWithAll; + onSeverityChange: (status: CaseSeverityWithAll) => void; + isLoading: boolean; + isDisabled: boolean; +} + +export const SeverityFilter: React.FC = ({ + selectedSeverity, + onSeverityChange, + isLoading, + isDisabled, +}) => { + const caseSeverities = Object.keys(severitiesWithAll) as CaseSeverityWithAll[]; + const options: Array> = caseSeverities.map( + (severity) => { + const severityData = severitiesWithAll[severity]; + return { + value: severity, + inputDisplay: ( + + + {severity === SeverityAll ? ( + {severityData.label} + ) : ( + {severityData.label} + )} + + + ), + }; + } + ); + + return ( + + ); +}; +SeverityFilter.displayName = 'SeverityFilter'; diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx index 5e83c33717abd15..ff1c00b56d0311f 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx @@ -10,11 +10,12 @@ import { mount } from 'enzyme'; import { CaseStatuses } from '../../../common/api'; import { OBSERVABILITY_OWNER, SECURITY_SOLUTION_OWNER } from '../../../common/constants'; -import { TestProviders } from '../../common/mock'; +import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; import { useGetTags } from '../../containers/use_get_tags'; import { useGetReporters } from '../../containers/use_get_reporters'; import { DEFAULT_FILTER_OPTIONS } from '../../containers/use_get_cases'; import { CasesTableFilters } from './table_filters'; +import userEvent from '@testing-library/user-event'; jest.mock('../../containers/use_get_reporters'); jest.mock('../../containers/use_get_tags'); @@ -35,7 +36,9 @@ const props = { }; describe('CasesTableFilters ', () => { + let appMockRender: AppMockRenderer; beforeEach(() => { + appMockRender = createAppMockRenderer(); jest.clearAllMocks(); (useGetTags as jest.Mock).mockReturnValue({ tags: ['coke', 'pepsi'], fetchTags }); (useGetReporters as jest.Mock).mockReturnValue({ @@ -57,6 +60,19 @@ describe('CasesTableFilters ', () => { expect(wrapper.find(`[data-test-subj="case-status-filter"]`).first().exists()).toBeTruthy(); }); + it('should render the case severity filter dropdown', () => { + const result = appMockRender.render(); + expect(result.getByTestId('case-severity-filter')).toBeTruthy(); + }); + + it('should call onFilterChange when the severity filter changes', () => { + const result = appMockRender.render(); + userEvent.click(result.getByTestId('case-severity-filter')); + userEvent.click(result.getByTestId('case-severity-filter-high')); + + expect(onFilterChanged).toBeCalledWith({ severity: 'high' }); + }); + it('should call onFilterChange when selected tags change', () => { const wrapper = mount( diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx index faee469d1c4bc58..0a34e756e37a63a 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx @@ -10,7 +10,12 @@ import { isEqual } from 'lodash/fp'; import styled from 'styled-components'; import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiFilterGroup, EuiButton } from '@elastic/eui'; -import { StatusAll, CaseStatusWithAllStatus } from '../../../common/ui/types'; +import { + StatusAll, + CaseStatusWithAllStatus, + SeverityAll, + CaseSeverityWithAll, +} from '../../../common/ui/types'; import { CaseStatuses } from '../../../common/api'; import { FilterOptions } from '../../containers/types'; import { useGetTags } from '../../containers/use_get_tags'; @@ -18,6 +23,7 @@ import { useGetReporters } from '../../containers/use_get_reporters'; import { FilterPopover } from '../filter_popover'; import { StatusFilter } from './status_filter'; import * as i18n from './translations'; +import { SeverityFilter } from './severity_filter'; interface CasesTableFiltersProps { countClosedCases: number | null; @@ -39,6 +45,12 @@ const StatusFilterWrapper = styled(EuiFlexItem)` } `; +const SeverityFilterWrapper = styled(EuiFlexItem)` + && { + flex-basis: 180px; + } +`; + /** * Collection of filters for filtering data within the CasesTable. Contains search bar, * and tag selection @@ -48,6 +60,7 @@ const StatusFilterWrapper = styled(EuiFlexItem)` const defaultInitial = { search: '', + severity: SeverityAll, reporters: [], status: StatusAll, tags: [], @@ -151,6 +164,13 @@ const CasesTableFiltersComponent = ({ [onFilterChanged] ); + const onSeverityChanged = useCallback( + (severity: CaseSeverityWithAll) => { + onFilterChanged({ severity }); + }, + [onFilterChanged] + ); + const stats = useMemo( () => ({ [StatusAll]: null, @@ -181,6 +201,14 @@ const CasesTableFiltersComponent = ({ onSearch={handleOnSearch} /> + + + onUpdateField({ key: 'tags', value: newTags }), [onUpdateField] ); + + const onUpdateSeverity = useCallback( + (newSeverity: CaseSeverity) => onUpdateField({ key: 'severity', value: newSeverity }), + [onUpdateField] + ); + const { loading: isLoadingConnectors, connectors } = useConnectors(); const [connectorName, isValidConnector] = useMemo(() => { @@ -180,6 +188,12 @@ export const CaseViewActivity = ({ )} + (value); + if (caseData.severity !== value) { + callUpdate('severity', severityUpdate); + } default: return null; } diff --git a/x-pack/plugins/cases/public/components/create/form.tsx b/x-pack/plugins/cases/public/components/create/form.tsx index ac2729564b387b4..50a3c69f2073e4f 100644 --- a/x-pack/plugins/cases/public/components/create/form.tsx +++ b/x-pack/plugins/cases/public/components/create/form.tsx @@ -35,6 +35,7 @@ import { CreateCaseOwnerSelector } from './owner_selector'; import { useCasesContext } from '../cases_context/use_cases_context'; import { useAvailableCasesOwners } from '../app/use_available_owners'; import { CaseAttachments } from '../../types'; +import { Severity } from './severity'; interface ContainerProps { big?: boolean; @@ -88,6 +89,9 @@ export const CreateCaseFormFields: React.FC = React.m + + + {canShowCaseSolutionSelection && ( = React.m + ), }), diff --git a/x-pack/plugins/cases/public/components/create/form_context.test.tsx b/x-pack/plugins/cases/public/components/create/form_context.test.tsx index 634f518ae5ebd1a..bfa4f391458da44 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.test.tsx @@ -10,7 +10,7 @@ import { mount, ReactWrapper } from 'enzyme'; import { act, RenderResult, waitFor, within } from '@testing-library/react'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { CommentType, ConnectorTypes } from '../../../common/api'; +import { CaseSeverity, CommentType, ConnectorTypes } from '../../../common/api'; import { useKibana } from '../../common/lib/kibana'; import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; import { usePostCase } from '../../containers/use_post_case'; @@ -182,6 +182,7 @@ describe('Create case', () => { ); expect(renderResult.getByTestId('caseTitle')).toBeTruthy(); + expect(renderResult.getByTestId('caseSeverity')).toBeTruthy(); expect(renderResult.getByTestId('caseDescription')).toBeTruthy(); expect(renderResult.getByTestId('caseTags')).toBeTruthy(); expect(renderResult.getByTestId('caseConnectors')).toBeTruthy(); @@ -208,6 +209,34 @@ describe('Create case', () => { }); }); + it('should post a case on submit click with the selected severity', async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const renderResult = mockedContext.render( + + + + + ); + + await fillFormReactTestingLib(renderResult); + + userEvent.click(renderResult.getByTestId('case-severity-selection')); + expect(renderResult.getByTestId('case-severity-selection-high')).toBeTruthy(); + userEvent.click(renderResult.getByTestId('case-severity-selection-high')); + + userEvent.click(renderResult.getByTestId('create-case-submit')); + await waitFor(() => { + expect(postCase).toBeCalledWith({ + ...sampleData, + severity: CaseSeverity.HIGH, + }); + }); + }); + it('does not submits the title when the length is longer than 64 characters', async () => { const longTitle = 'This is a title that should not be saved as it is longer than 64 characters.'; @@ -285,6 +314,18 @@ describe('Create case', () => { ); }); + it('should select LOW as the default severity', async () => { + const renderResult = mockedContext.render( + + + + + ); + expect(renderResult.getByTestId('caseSeverity')).toBeTruthy(); + // there should be 2 low elements. one for the options popover and one for the displayed one. + expect(renderResult.getAllByTestId('case-severity-selection-low').length).toBe(2); + }); + it('should select the default connector set in the configuration', async () => { useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, diff --git a/x-pack/plugins/cases/public/components/create/form_context.tsx b/x-pack/plugins/cases/public/components/create/form_context.tsx index 4385053a8c8c02a..a65e9f5960e9ddb 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.tsx @@ -14,7 +14,7 @@ import { usePostPushToService } from '../../containers/use_post_push_to_service' import { useConnectors } from '../../containers/configure/use_connectors'; import { Case } from '../../containers/types'; -import { NONE_CONNECTOR_ID } from '../../../common/api'; +import { CaseSeverity, NONE_CONNECTOR_ID } from '../../../common/api'; import { UseCreateAttachments, useCreateAttachments, @@ -28,6 +28,7 @@ const initialCaseValue: FormProps = { description: '', tags: [], title: '', + severity: CaseSeverity.LOW, connectorId: NONE_CONNECTOR_ID, fields: null, syncAlerts: true, diff --git a/x-pack/plugins/cases/public/components/create/mock.ts b/x-pack/plugins/cases/public/components/create/mock.ts index 8ab515c79f67e10..38d57bf24781e9b 100644 --- a/x-pack/plugins/cases/public/components/create/mock.ts +++ b/x-pack/plugins/cases/public/components/create/mock.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CasePostRequest, ConnectorTypes } from '../../../common/api'; +import { CasePostRequest, CaseSeverity, ConnectorTypes } from '../../../common/api'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { choices } from '../connectors/mock'; @@ -13,6 +13,7 @@ export const sampleTags = ['coke', 'pepsi']; export const sampleData: CasePostRequest = { description: 'what a great description', tags: sampleTags, + severity: CaseSeverity.LOW, title: 'what a cool title', connector: { fields: null, diff --git a/x-pack/plugins/cases/public/components/create/schema.tsx b/x-pack/plugins/cases/public/components/create/schema.tsx index b7c363b2639982a..d72b1cc523f0df9 100644 --- a/x-pack/plugins/cases/public/components/create/schema.tsx +++ b/x-pack/plugins/cases/public/components/create/schema.tsx @@ -17,6 +17,7 @@ import { import * as i18n from './translations'; import { OptionalFieldLabel } from './optional_field_label'; +import { SEVERITY_TITLE } from '../severity/translations'; const { emptyField, maxLengthField } = fieldValidators; export const schemaTags = { @@ -83,6 +84,9 @@ export const schema: FormSchema = { ], }, tags: schemaTags, + severity: { + label: SEVERITY_TITLE, + }, connectorId: { type: FIELD_TYPES.SUPER_SELECT, label: i18n.CONNECTORS, diff --git a/x-pack/plugins/cases/public/components/create/severity.test.tsx b/x-pack/plugins/cases/public/components/create/severity.test.tsx new file mode 100644 index 000000000000000..d2434a37a439246 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/severity.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CaseSeverity } from '../../../common/api'; +import React from 'react'; +import { AppMockRenderer, createAppMockRenderer } from '../../common/mock'; +import { Form, FormHook, useForm } from '../../common/shared_imports'; +import { Severity } from './severity'; +import { FormProps, schema } from './schema'; +import userEvent from '@testing-library/user-event'; +import { waitFor } from '@testing-library/dom'; + +let globalForm: FormHook; +const MockHookWrapperComponent: React.FC = ({ children }) => { + const { form } = useForm({ + defaultValue: { severity: CaseSeverity.LOW }, + schema: { + severity: schema.severity, + }, + }); + + globalForm = form; + + return {children}; +}; +describe('Severity form field', () => { + let appMockRender: AppMockRenderer; + beforeEach(() => { + appMockRender = createAppMockRenderer(); + }); + it('renders', () => { + const result = appMockRender.render( + + + + ); + expect(result.getByTestId('caseSeverity')).toBeTruthy(); + expect(result.getByTestId('case-severity-selection')).not.toHaveAttribute('disabled'); + }); + + // default to LOW in this test configuration + it('defaults to the correct value', () => { + const result = appMockRender.render( + + + + ); + expect(result.getByTestId('caseSeverity')).toBeTruthy(); + // two items. one for the popover one for the selected field + expect(result.getAllByTestId('case-severity-selection-low').length).toBe(2); + }); + + it('selects the correct value when changed', async () => { + const result = appMockRender.render( + + + + ); + expect(result.getByTestId('caseSeverity')).toBeTruthy(); + userEvent.click(result.getByTestId('case-severity-selection')); + userEvent.click(result.getByTestId('case-severity-selection-high')); + await waitFor(() => { + expect(globalForm.getFormData()).toEqual({ severity: 'high' }); + }); + }); + + it('disables when loading data', () => { + const result = appMockRender.render( + + + + ); + expect(result.getByTestId('case-severity-selection')).toHaveAttribute('disabled'); + }); +}); diff --git a/x-pack/plugins/cases/public/components/create/severity.tsx b/x-pack/plugins/cases/public/components/create/severity.tsx new file mode 100644 index 000000000000000..730eab5d77ac6c1 --- /dev/null +++ b/x-pack/plugins/cases/public/components/create/severity.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFormRow } from '@elastic/eui'; +import React, { memo } from 'react'; +import { CaseSeverity } from '../../../common/api'; +import { UseField, useFormContext, useFormData } from '../../common/shared_imports'; +import { SeveritySelector } from '../severity/selector'; +import { SEVERITY_TITLE } from '../severity/translations'; + +interface Props { + isLoading: boolean; +} + +const SeverityFieldFormComponent = ({ isLoading }: { isLoading: boolean }) => { + const { setFieldValue } = useFormContext(); + const [{ severity }] = useFormData({ watch: ['severity'] }); + const onSeverityChange = (newSeverity: CaseSeverity) => { + setFieldValue('severity', newSeverity); + }; + return ( + + + + ); +}; +SeverityFieldFormComponent.displayName = 'SeverityFieldForm'; + +const SeverityComponent: React.FC = ({ isLoading }) => ( + +); + +SeverityComponent.displayName = 'SeverityComponent'; + +export const Severity = memo(SeverityComponent); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx b/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx index 5d84454d038dbcd..f0e951c89326fbc 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/eui_form.tsx @@ -26,7 +26,7 @@ type MarkdownEditorFormProps = EuiMarkdownEditorProps & { const BottomContentWrapper = styled(EuiFlexGroup)` ${({ theme }) => ` - padding: ${theme.eui.ruleMargins.marginSmall} 0; + padding: ${theme.eui.euiSizeM} 0; `} `; diff --git a/x-pack/plugins/cases/public/components/severity/config.ts b/x-pack/plugins/cases/public/components/severity/config.ts new file mode 100644 index 000000000000000..e22f7bda54665ae --- /dev/null +++ b/x-pack/plugins/cases/public/components/severity/config.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 { euiLightVars } from '@kbn/ui-theme'; +import { CaseSeverity } from '../../../common/api'; +import { SeverityAll } from '../../containers/types'; +import { ALL_SEVERITIES, CRITICAL, HIGH, LOW, MEDIUM } from './translations'; + +export const severities = { + [CaseSeverity.LOW]: { + color: euiLightVars.euiColorVis0, + label: LOW, + }, + [CaseSeverity.MEDIUM]: { + color: euiLightVars.euiColorVis5, + label: MEDIUM, + }, + [CaseSeverity.HIGH]: { + color: euiLightVars.euiColorVis7, + label: HIGH, + }, + [CaseSeverity.CRITICAL]: { + color: euiLightVars.euiColorVis9, + label: CRITICAL, + }, +}; + +export const severitiesWithAll = { + [SeverityAll]: { + color: 'transparent', + label: ALL_SEVERITIES, + }, + ...severities, +}; diff --git a/x-pack/plugins/cases/public/components/severity/selector.test.tsx b/x-pack/plugins/cases/public/components/severity/selector.test.tsx new file mode 100644 index 000000000000000..126dc64e7af1bdb --- /dev/null +++ b/x-pack/plugins/cases/public/components/severity/selector.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CaseSeverity } from '../../../common/api'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { SeveritySelector } from './selector'; +import userEvent from '@testing-library/user-event'; + +describe('Severity field selector', () => { + const onSeverityChange = jest.fn(); + it('renders a list of severity fields', () => { + const result = render( + + ); + + expect(result.getByTestId('case-severity-selection')).toBeTruthy(); + expect(result.getAllByTestId('case-severity-selection-medium').length).toBeTruthy(); + }); + + it('renders a list of severity options when clicked', () => { + const result = render( + + ); + userEvent.click(result.getByTestId('case-severity-selection')); + expect(result.getByTestId('case-severity-selection-low')).toBeTruthy(); + expect(result.getAllByTestId('case-severity-selection-medium').length).toBeTruthy(); + expect(result.getByTestId('case-severity-selection-high')).toBeTruthy(); + expect(result.getByTestId('case-severity-selection-critical')).toBeTruthy(); + }); + + it('calls onSeverityChange with the newly selected severity when clicked', () => { + const result = render( + + ); + userEvent.click(result.getByTestId('case-severity-selection')); + expect(result.getByTestId('case-severity-selection-low')).toBeTruthy(); + userEvent.click(result.getByTestId('case-severity-selection-low')); + expect(onSeverityChange).toHaveBeenLastCalledWith('low'); + }); +}); diff --git a/x-pack/plugins/cases/public/components/severity/selector.tsx b/x-pack/plugins/cases/public/components/severity/selector.tsx new file mode 100644 index 000000000000000..0d1ff4b319f2b79 --- /dev/null +++ b/x-pack/plugins/cases/public/components/severity/selector.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiFlexGroup, + EuiFlexItem, + EuiHealth, + EuiSuperSelect, + EuiSuperSelectOption, +} from '@elastic/eui'; +import React from 'react'; +import { CaseSeverity } from '../../../common/api'; +import { severities } from './config'; + +interface Props { + selectedSeverity: CaseSeverity; + onSeverityChange: (status: CaseSeverity) => void; + isLoading: boolean; + isDisabled: boolean; +} + +export const SeveritySelector: React.FC = ({ + selectedSeverity, + onSeverityChange, + isLoading, + isDisabled, +}) => { + const caseSeverities = Object.keys(severities) as CaseSeverity[]; + const options: Array> = caseSeverities.map((severity) => { + const severityData = severities[severity]; + return { + value: severity, + inputDisplay: ( + + + {severityData.label} + + + ), + }; + }); + + return ( + + ); +}; +SeveritySelector.displayName = 'SeveritySelector'; diff --git a/x-pack/plugins/cases/public/components/severity/sidebar_selector.tsx b/x-pack/plugins/cases/public/components/severity/sidebar_selector.tsx new file mode 100644 index 000000000000000..ff591e342793f79 --- /dev/null +++ b/x-pack/plugins/cases/public/components/severity/sidebar_selector.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexItem, EuiHorizontalRule, EuiSpacer, EuiText } from '@elastic/eui'; +import React from 'react'; +import { CaseSeverity } from '../../../common/api'; +import { SeveritySelector } from './selector'; +import { SEVERITY_TITLE } from './translations'; + +interface Props { + selectedSeverity: CaseSeverity; + onSeverityChange: (status: CaseSeverity) => void; + isLoading: boolean; + isDisabled: boolean; +} + +export const SeveritySidebarSelector: React.FC = ({ + selectedSeverity, + onSeverityChange, + isLoading, + isDisabled, +}) => { + return ( + + +

    {SEVERITY_TITLE}

    +
    + + + +
    + ); +}; +SeveritySidebarSelector.displayName = 'SeveritySidebarSelector'; diff --git a/x-pack/plugins/cases/public/components/severity/translations.ts b/x-pack/plugins/cases/public/components/severity/translations.ts new file mode 100644 index 000000000000000..b70dbebe41d1944 --- /dev/null +++ b/x-pack/plugins/cases/public/components/severity/translations.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const LOW = i18n.translate('xpack.cases.severity.low', { + defaultMessage: 'Low', +}); + +export const MEDIUM = i18n.translate('xpack.cases.severity.medium', { + defaultMessage: 'Medium', +}); + +export const HIGH = i18n.translate('xpack.cases.severity.high', { + defaultMessage: 'High', +}); + +export const CRITICAL = i18n.translate('xpack.cases.severity.critical', { + defaultMessage: 'Critical', +}); + +export const SEVERITY_TITLE = i18n.translate('xpack.cases.severity.title', { + defaultMessage: 'Severity', +}); + +export const ALL_SEVERITIES = i18n.translate('xpack.cases.severity.all', { + defaultMessage: 'All severities', +}); diff --git a/x-pack/plugins/cases/public/components/status/index.ts b/x-pack/plugins/cases/public/components/status/index.ts index 94d7cb6a3183020..a261b903ae9ce2e 100644 --- a/x-pack/plugins/cases/public/components/status/index.ts +++ b/x-pack/plugins/cases/public/components/status/index.ts @@ -7,5 +7,5 @@ export * from './status'; export * from './config'; -export * from './stats'; +export * from './status_stats'; export * from './types'; diff --git a/x-pack/plugins/cases/public/components/status/stats.test.tsx b/x-pack/plugins/cases/public/components/status/status_stats.test.tsx similarity index 75% rename from x-pack/plugins/cases/public/components/status/stats.test.tsx rename to x-pack/plugins/cases/public/components/status/status_stats.test.tsx index ea0f54bf8055bdc..28292496dd91780 100644 --- a/x-pack/plugins/cases/public/components/status/stats.test.tsx +++ b/x-pack/plugins/cases/public/components/status/status_stats.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { mount } from 'enzyme'; import { CaseStatuses } from '../../../common/api'; -import { Stats } from './stats'; +import { StatusStats } from './status_stats'; describe('Stats', () => { const defaultProps = { @@ -19,13 +19,13 @@ describe('Stats', () => { dataTestSubj: 'test-stats', }; it('it renders', async () => { - const wrapper = mount(); + const wrapper = mount(); expect(wrapper.find(`[data-test-subj="test-stats"]`).exists()).toBeTruthy(); }); it('shows the count', async () => { - const wrapper = mount(); + const wrapper = mount(); expect( wrapper.find(`[data-test-subj="test-stats"] .euiDescriptionList__description`).first().text() @@ -33,14 +33,14 @@ describe('Stats', () => { }); it('shows the loading spinner', async () => { - const wrapper = mount(); + const wrapper = mount(); expect(wrapper.find(`[data-test-subj="test-stats-loading-spinner"]`).exists()).toBeTruthy(); }); describe('Status title', () => { it('shows the correct title for status open', async () => { - const wrapper = mount(); + const wrapper = mount(); expect( wrapper.find(`[data-test-subj="test-stats"] .euiDescriptionList__title`).first().text() @@ -48,7 +48,9 @@ describe('Stats', () => { }); it('shows the correct title for status in-progress', async () => { - const wrapper = mount(); + const wrapper = mount( + + ); expect( wrapper.find(`[data-test-subj="test-stats"] .euiDescriptionList__title`).first().text() @@ -56,7 +58,7 @@ describe('Stats', () => { }); it('shows the correct title for status closed', async () => { - const wrapper = mount(); + const wrapper = mount(); expect( wrapper.find(`[data-test-subj="test-stats"] .euiDescriptionList__title`).first().text() diff --git a/x-pack/plugins/cases/public/components/status/stats.tsx b/x-pack/plugins/cases/public/components/status/status_stats.tsx similarity index 80% rename from x-pack/plugins/cases/public/components/status/stats.tsx rename to x-pack/plugins/cases/public/components/status/status_stats.tsx index 98720ad75a6564f..56f4259f87ea6ee 100644 --- a/x-pack/plugins/cases/public/components/status/stats.tsx +++ b/x-pack/plugins/cases/public/components/status/status_stats.tsx @@ -17,7 +17,12 @@ export interface Props { dataTestSubj?: string; } -const StatsComponent: React.FC = ({ caseCount, caseStatus, isLoading, dataTestSubj }) => { +const StatusStatsComponent: React.FC = ({ + caseCount, + caseStatus, + isLoading, + dataTestSubj, +}) => { const statusStats = useMemo( () => [ { @@ -25,7 +30,7 @@ const StatsComponent: React.FC = ({ caseCount, caseStatus, isLoading, dat description: isLoading ? ( ) : ( - caseCount ?? 'N/A' + caseCount ?? '-' ), }, ], @@ -36,5 +41,5 @@ const StatsComponent: React.FC = ({ caseCount, caseStatus, isLoading, dat ); }; -StatsComponent.displayName = 'StatsComponent'; -export const Stats = memo(StatsComponent); +StatusStatsComponent.displayName = 'StatusStats'; +export const StatusStats = memo(StatusStatsComponent); diff --git a/x-pack/plugins/cases/public/components/status/translations.ts b/x-pack/plugins/cases/public/components/status/translations.ts index b3eadfd681ba5f1..4fe75bbcfac7ab7 100644 --- a/x-pack/plugins/cases/public/components/status/translations.ts +++ b/x-pack/plugins/cases/public/components/status/translations.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; export * from '../../common/translations'; export const ALL = i18n.translate('xpack.cases.status.all', { - defaultMessage: 'All', + defaultMessage: 'All status', }); export const OPEN = i18n.translate('xpack.cases.status.open', { diff --git a/x-pack/plugins/cases/public/components/user_actions/builder.tsx b/x-pack/plugins/cases/public/components/user_actions/builder.tsx index 019e37396a7ce42..36298bbae601b33 100644 --- a/x-pack/plugins/cases/public/components/user_actions/builder.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/builder.tsx @@ -10,6 +10,7 @@ import { createConnectorUserActionBuilder } from './connector'; import { createDescriptionUserActionBuilder } from './description'; import { createPushedUserActionBuilder } from './pushed'; import { createSettingsUserActionBuilder } from './settings'; +import { createSeverityUserActionBuilder } from './severity'; import { createStatusUserActionBuilder } from './status'; import { createTagsUserActionBuilder } from './tags'; import { createTitleUserActionBuilder } from './title'; @@ -20,10 +21,7 @@ export const builderMap: UserActionBuilderMap = { tags: createTagsUserActionBuilder, title: createTitleUserActionBuilder, status: createStatusUserActionBuilder, - // TODO: Build severity user action - severity: () => ({ - build: () => [], - }), + severity: createSeverityUserActionBuilder, pushed: createPushedUserActionBuilder, comment: createCommentUserActionBuilder, description: createDescriptionUserActionBuilder, diff --git a/x-pack/plugins/cases/public/components/user_actions/severity.test.tsx b/x-pack/plugins/cases/public/components/user_actions/severity.test.tsx new file mode 100644 index 000000000000000..d92a5cb5a153dd5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/severity.test.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiCommentList } from '@elastic/eui'; +import { Actions, CaseSeverity } from '../../../common/api'; +import React from 'react'; +import { AppMockRenderer, createAppMockRenderer } from '../../common/mock'; +import { getUserAction } from '../../containers/mock'; +import { getMockBuilderArgs } from './mock'; +import { createSeverityUserActionBuilder } from './severity'; + +jest.mock('../../common/lib/kibana'); +jest.mock('../../common/navigation/hooks'); + +const builderArgs = getMockBuilderArgs(); +describe('createSeverityUserActionBuilder', () => { + let appMockRenderer: AppMockRenderer; + beforeEach(() => { + appMockRenderer = createAppMockRenderer(); + }); + it('renders correctly', () => { + const userAction = getUserAction('severity', Actions.update, { + payload: { severity: CaseSeverity.LOW }, + }); + const builder = createSeverityUserActionBuilder({ + ...builderArgs, + userAction, + }); + const createdUserAction = builder.build(); + + const result = appMockRenderer.render(); + expect(result.getByTestId('severity-update-user-action-severity-title')).toBeTruthy(); + expect(result.getByTestId('severity-update-user-action-severity-title-low')).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/user_actions/severity.tsx b/x-pack/plugins/cases/public/components/user_actions/severity.tsx new file mode 100644 index 000000000000000..3e2cf8605b080e7 --- /dev/null +++ b/x-pack/plugins/cases/public/components/user_actions/severity.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiHealth } from '@elastic/eui'; +import React from 'react'; +import { SeverityUserAction } from '../../../common/api/cases/user_actions/severity'; +import { SET_SEVERITY_TO } from '../create/translations'; +import { createCommonUpdateUserActionBuilder } from './common'; +import { UserActionBuilder, UserActionResponse } from './types'; +import { severities } from '../severity/config'; + +const getLabelTitle = (userAction: UserActionResponse) => { + const severity = userAction.payload.severity; + const severityData = severities[severity]; + if (severityData === undefined) { + return null; + } + return ( + + {SET_SEVERITY_TO} + + {severityData.label} + + + ); +}; + +export const createSeverityUserActionBuilder: UserActionBuilder = ({ + userAction, + handleOutlineComment, +}) => ({ + build: () => { + const severityUserAction = userAction as UserActionResponse; + const label = getLabelTitle(severityUserAction); + const commonBuilder = createCommonUpdateUserActionBuilder({ + userAction, + handleOutlineComment, + label, + icon: 'dot', + }); + + return commonBuilder.build(); + }, +}); diff --git a/x-pack/plugins/cases/public/containers/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/__mocks__/api.ts index 4b2029a83d6dd84..c330fb7eb9cf0c6 100644 --- a/x-pack/plugins/cases/public/containers/__mocks__/api.ts +++ b/x-pack/plugins/cases/public/containers/__mocks__/api.ts @@ -29,7 +29,7 @@ import { respReporters, tags, } from '../mock'; -import { ResolvedCase } from '../../../common/ui/types'; +import { ResolvedCase, SeverityAll } from '../../../common/ui/types'; import { CasePatchRequest, CasePostRequest, @@ -71,6 +71,7 @@ export const getCaseUserActions = async ( export const getCases = async ({ filterOptions = { + severity: SeverityAll, search: '', reporters: [], status: CaseStatuses.open, diff --git a/x-pack/plugins/cases/public/containers/api.test.tsx b/x-pack/plugins/cases/public/containers/api.test.tsx index 0b996ec1c7a0742..e37955b2768c01a 100644 --- a/x-pack/plugins/cases/public/containers/api.test.tsx +++ b/x-pack/plugins/cases/public/containers/api.test.tsx @@ -8,7 +8,7 @@ import { httpServiceMock } from '@kbn/core/public/mocks'; import { KibanaServices } from '../common/lib/kibana'; -import { ConnectorTypes, CommentType, CaseStatuses } from '../../common/api'; +import { ConnectorTypes, CommentType, CaseStatuses, CaseSeverity } from '../../common/api'; import { CASES_URL, INTERNAL_BULK_CREATE_ATTACHMENTS_URL, @@ -206,6 +206,47 @@ describe('Case Configuration API', () => { }); }); + test('should apply the severity field correctly (with severity value)', async () => { + await getCases({ + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + severity: CaseSeverity.HIGH, + }, + queryParams: DEFAULT_QUERY_PARAMS, + signal: abortCtrl.signal, + }); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { + method: 'GET', + query: { + ...DEFAULT_QUERY_PARAMS, + reporters: [], + tags: [], + severity: CaseSeverity.HIGH, + }, + signal: abortCtrl.signal, + }); + }); + + test('should not send the severity field with "all" severity value', async () => { + await getCases({ + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + severity: 'all', + }, + queryParams: DEFAULT_QUERY_PARAMS, + signal: abortCtrl.signal, + }); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/_find`, { + method: 'GET', + query: { + ...DEFAULT_QUERY_PARAMS, + reporters: [], + tags: [], + }, + signal: abortCtrl.signal, + }); + }); + test('should handle tags with weird chars', async () => { const weirdTags: string[] = ['(', '"double"']; diff --git a/x-pack/plugins/cases/public/containers/api.ts b/x-pack/plugins/cases/public/containers/api.ts index 63a2ea794e065a8..b0f00ad202c5f3f 100644 --- a/x-pack/plugins/cases/public/containers/api.ts +++ b/x-pack/plugins/cases/public/containers/api.ts @@ -5,11 +5,11 @@ * 2.0. */ -import { omit } from 'lodash'; import { Cases, FetchCasesProps, ResolvedCase, + SeverityAll, SortFieldCase, StatusAll, } from '../../common/ui/types'; @@ -149,6 +149,7 @@ export const getCaseUserActions = async ( export const getCases = async ({ filterOptions = { search: '', + severity: SeverityAll, reporters: [], status: StatusAll, tags: [], @@ -163,9 +164,10 @@ export const getCases = async ({ signal, }: FetchCasesProps): Promise => { const query = { + ...(filterOptions.status !== StatusAll ? { status: filterOptions.status } : {}), + ...(filterOptions.severity !== SeverityAll ? { severity: filterOptions.severity } : {}), reporters: filterOptions.reporters.map((r) => r.username ?? '').filter((r) => r !== ''), tags: filterOptions.tags, - status: filterOptions.status, ...(filterOptions.search.length > 0 ? { search: filterOptions.search } : {}), ...(filterOptions.owner.length > 0 ? { owner: filterOptions.owner } : {}), ...queryParams, @@ -173,7 +175,7 @@ export const getCases = async ({ const response = await KibanaServices.get().http.fetch(`${CASES_URL}/_find`, { method: 'GET', - query: query.status === StatusAll ? omit(query, ['status']) : query, + query, signal, }); diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index ed9e9ebd1ff8f0e..8cf413d08f2fded 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -12,6 +12,7 @@ import type { SingleCaseMetrics, SingleCaseMetricsFeature, AlertComment, + CasesMetrics, } from '../../common/ui/types'; import { Actions, @@ -292,6 +293,10 @@ export const casesStatus: CasesStatus = { countClosedCases: 130, }; +export const casesMetrics: CasesMetrics = { + mttr: 12, +}; + export const basicPush = { connectorId: '123', connectorName: 'connector name', diff --git a/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx index dee4d424c84def7..b689746a7af001f 100644 --- a/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { renderHook, act } from '@testing-library/react-hooks'; -import { CaseStatuses } from '../../common/api'; +import { CaseSeverity, CaseStatuses } from '../../common/api'; import { SECURITY_SOLUTION_OWNER } from '../../common/constants'; import { DEFAULT_FILTER_OPTIONS, @@ -219,6 +219,7 @@ describe('useGetCases', () => { const spyOnGetCases = jest.spyOn(api, 'getCases'); const newFilters = { search: 'new', + severity: CaseSeverity.LOW, tags: ['new'], status: CaseStatuses.closed, owner: [SECURITY_SOLUTION_OWNER], diff --git a/x-pack/plugins/cases/public/containers/use_get_cases.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.tsx index d817dc9d9ac0f66..f708d9828225284 100644 --- a/x-pack/plugins/cases/public/containers/use_get_cases.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases.tsx @@ -15,6 +15,7 @@ import { SortFieldCase, StatusAll, UpdateByKey, + SeverityAll, } from './types'; import { useToasts } from '../common/lib/kibana'; import * as i18n from './translations'; @@ -101,6 +102,7 @@ const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesS export const DEFAULT_FILTER_OPTIONS: FilterOptions = { search: '', + severity: SeverityAll, reporters: [], status: StatusAll, tags: [], diff --git a/x-pack/plugins/cases/public/containers/use_get_cases_metrics.test.tsx b/x-pack/plugins/cases/public/containers/use_get_cases_metrics.test.tsx new file mode 100644 index 000000000000000..6601a104d9f7d03 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_get_cases_metrics.test.tsx @@ -0,0 +1,128 @@ +/* + * 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 React from 'react'; +import { renderHook, act } from '@testing-library/react-hooks'; +import * as api from '../api'; +import { TestProviders } from '../common/mock'; +import { useGetCasesMetrics, UseGetCasesMetrics } from './use_get_cases_metrics'; +import { SECURITY_SOLUTION_OWNER } from '../../common/constants'; + +jest.mock('../api'); +jest.mock('../common/lib/kibana'); + +describe('useGetReporters', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('init', async () => { + const { result } = renderHook(() => useGetCasesMetrics(), { + wrapper: ({ children }) => {children}, + }); + + await act(async () => { + expect(result.current).toEqual({ + mttr: 0, + isLoading: true, + isError: false, + fetchCasesMetrics: result.current.fetchCasesMetrics, + }); + }); + }); + + it('calls getCasesMetrics api', async () => { + const spy = jest.spyOn(api, 'getCasesMetrics'); + await act(async () => { + const { waitForNextUpdate } = renderHook( + () => useGetCasesMetrics(), + { + wrapper: ({ children }) => {children}, + } + ); + + await waitForNextUpdate(); + expect(spy).toBeCalledWith({ + http: expect.anything(), + signal: expect.anything(), + query: { + features: ['mttr'], + owner: [SECURITY_SOLUTION_OWNER], + }, + }); + }); + }); + + it('fetch cases metrics', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => useGetCasesMetrics(), + { + wrapper: ({ children }) => {children}, + } + ); + + await waitForNextUpdate(); + expect(result.current).toEqual({ + mttr: 12, + isLoading: false, + isError: false, + fetchCasesMetrics: result.current.fetchCasesMetrics, + }); + }); + }); + + it('fetches metrics when fetchCasesMetrics is invoked', async () => { + const spy = jest.spyOn(api, 'getCasesMetrics'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => useGetCasesMetrics(), + { + wrapper: ({ children }) => {children}, + } + ); + + await waitForNextUpdate(); + expect(spy).toBeCalledWith({ + http: expect.anything(), + signal: expect.anything(), + query: { + features: ['mttr'], + owner: [SECURITY_SOLUTION_OWNER], + }, + }); + result.current.fetchCasesMetrics(); + await waitForNextUpdate(); + expect(spy).toHaveBeenCalledTimes(2); + }); + }); + + it('unhappy path', async () => { + const spy = jest.spyOn(api, 'getCasesMetrics'); + spy.mockImplementation(() => { + throw new Error('Oh on. this is impossible'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => useGetCasesMetrics(), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + mttr: 0, + isLoading: false, + isError: true, + fetchCasesMetrics: result.current.fetchCasesMetrics, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/use_get_cases_metrics.tsx b/x-pack/plugins/cases/public/containers/use_get_cases_metrics.tsx new file mode 100644 index 000000000000000..a5cb116acc55934 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_get_cases_metrics.tsx @@ -0,0 +1,92 @@ +/* + * 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 { useCallback, useEffect, useState, useRef } from 'react'; + +import { useCasesContext } from '../components/cases_context/use_cases_context'; +import * as i18n from './translations'; +import { useHttp, useToasts } from '../common/lib/kibana'; +import { getCasesMetrics } from '../api'; +import { CasesMetrics } from './types'; + +interface CasesMetricsState extends CasesMetrics { + isLoading: boolean; + isError: boolean; +} + +const initialData: CasesMetricsState = { + mttr: 0, + isLoading: true, + isError: false, +}; + +export interface UseGetCasesMetrics extends CasesMetricsState { + fetchCasesMetrics: () => void; +} + +export const useGetCasesMetrics = (): UseGetCasesMetrics => { + const http = useHttp(); + const { owner } = useCasesContext(); + const [casesMetricsState, setCasesMetricsState] = useState(initialData); + const toasts = useToasts(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); + + const fetchCasesMetrics = useCallback(async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + setCasesMetricsState({ + ...initialData, + isLoading: true, + }); + + const response = await getCasesMetrics({ + http, + signal: abortCtrlRef.current.signal, + query: { owner, features: ['mttr'] }, + }); + + if (!isCancelledRef.current) { + setCasesMetricsState({ + ...response, + isLoading: false, + isError: false, + }); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); + } + setCasesMetricsState({ + mttr: 0, + isLoading: false, + isError: true, + }); + } + } + }, [http, owner, toasts]); + + useEffect(() => { + fetchCasesMetrics(); + + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; + }, [fetchCasesMetrics]); + + return { + ...casesMetricsState, + fetchCasesMetrics, + }; +}; diff --git a/x-pack/plugins/cases/server/client/cases/find.ts b/x-pack/plugins/cases/server/client/cases/find.ts index b5d3cee05ced688..0c2222969284277 100644 --- a/x-pack/plugins/cases/server/client/cases/find.ts +++ b/x-pack/plugins/cases/server/client/cases/find.ts @@ -53,6 +53,7 @@ export const find = async ( reporters: queryParams.reporters, sortByField: queryParams.sortField, status: queryParams.status, + severity: queryParams.severity, owner: queryParams.owner, from: queryParams.from, to: queryParams.to, diff --git a/x-pack/plugins/cases/server/client/utils.ts b/x-pack/plugins/cases/server/client/utils.ts index faae6450c523819..334b974c06108dd 100644 --- a/x-pack/plugins/cases/server/client/utils.ts +++ b/x-pack/plugins/cases/server/client/utils.ts @@ -23,6 +23,7 @@ import { ContextTypeUserRt, excess, throwErrors, + CaseSeverity, } from '../../common/api'; import { combineFilterWithAuthorizationFilter } from '../authorization/utils'; import { @@ -114,6 +115,25 @@ export const addStatusFilter = ({ return filters.length > 1 ? nodeBuilder.and(filters) : filters[0]; }; +export const addSeverityFilter = ({ + severity, + appendFilter, + type = CASE_SAVED_OBJECT, +}: { + severity: CaseSeverity; + appendFilter?: KueryNode; + type?: string; +}): KueryNode => { + const filters: KueryNode[] = []; + filters.push(nodeBuilder.is(`${type}.attributes.severity`, severity)); + + if (appendFilter) { + filters.push(appendFilter); + } + + return filters.length > 1 ? nodeBuilder.and(filters) : filters[0]; +}; + interface FilterField { filters?: string | string[]; field: string; @@ -222,6 +242,7 @@ export const constructQueryOptions = ({ tags, reporters, status, + severity, sortByField, owner, authorizationFilter, @@ -231,6 +252,7 @@ export const constructQueryOptions = ({ tags?: string | string[]; reporters?: string | string[]; status?: CaseStatuses; + severity?: CaseSeverity; sortByField?: string; owner?: string | string[]; authorizationFilter?: KueryNode; @@ -250,10 +272,12 @@ export const constructQueryOptions = ({ const ownerFilter = buildFilter({ filters: owner ?? [], field: OWNER_FIELD, operator: 'or' }); const statusFilter = status != null ? addStatusFilter({ status }) : undefined; + const severityFilter = severity != null ? addSeverityFilter({ severity }) : undefined; const rangeFilter = buildRangeFilter({ from, to }); const filters: KueryNode[] = [ statusFilter, + severityFilter, tagsFilter, reportersFilter, rangeFilter, diff --git a/x-pack/plugins/cloud/common/register_cloud_deployment_id_analytics_context.test.ts b/x-pack/plugins/cloud/common/register_cloud_deployment_id_analytics_context.test.ts new file mode 100644 index 000000000000000..a6dc1f59b00e311 --- /dev/null +++ b/x-pack/plugins/cloud/common/register_cloud_deployment_id_analytics_context.test.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { firstValueFrom } from 'rxjs'; +import { registerCloudDeploymentIdAnalyticsContext } from './register_cloud_deployment_id_analytics_context'; + +describe('registerCloudDeploymentIdAnalyticsContext', () => { + let analytics: { registerContextProvider: jest.Mock }; + beforeEach(() => { + analytics = { + registerContextProvider: jest.fn(), + }; + }); + + test('it does not register the context provider if cloudId not provided', () => { + registerCloudDeploymentIdAnalyticsContext(analytics); + expect(analytics.registerContextProvider).not.toHaveBeenCalled(); + }); + + test('it registers the context provider and emits the cloudId', async () => { + registerCloudDeploymentIdAnalyticsContext(analytics, 'cloud_id'); + expect(analytics.registerContextProvider).toHaveBeenCalledTimes(1); + const [{ context$ }] = analytics.registerContextProvider.mock.calls[0]; + await expect(firstValueFrom(context$)).resolves.toEqual({ cloudId: 'cloud_id' }); + }); +}); diff --git a/x-pack/plugins/cloud/common/register_cloud_deployment_id_analytics_context.ts b/x-pack/plugins/cloud/common/register_cloud_deployment_id_analytics_context.ts new file mode 100644 index 000000000000000..e8bdc6b37b50c53 --- /dev/null +++ b/x-pack/plugins/cloud/common/register_cloud_deployment_id_analytics_context.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AnalyticsClient } from '@kbn/analytics-client'; +import { of } from 'rxjs'; + +export function registerCloudDeploymentIdAnalyticsContext( + analytics: Pick, + cloudId?: string +) { + if (!cloudId) { + return; + } + analytics.registerContextProvider({ + name: 'Cloud Deployment ID', + context$: of({ cloudId }), + schema: { + cloudId: { + type: 'keyword', + _meta: { description: 'The Cloud Deployment ID' }, + }, + }, + }); +} diff --git a/x-pack/plugins/cloud/public/plugin.test.ts b/x-pack/plugins/cloud/public/plugin.test.ts index 5e0294178a5daf6..36be9e590f216b5 100644 --- a/x-pack/plugins/cloud/public/plugin.test.ts +++ b/x-pack/plugins/cloud/public/plugin.test.ts @@ -9,9 +9,9 @@ import { nextTick } from '@kbn/test-jest-helpers'; import { coreMock } from '@kbn/core/public/mocks'; import { homePluginMock } from '@kbn/home-plugin/public/mocks'; import { securityMock } from '@kbn/security-plugin/public/mocks'; -import { CloudPlugin, CloudConfigType, loadUserId } from './plugin'; -import { firstValueFrom, Observable, Subject } from 'rxjs'; -import { KibanaExecutionContext } from '@kbn/core/public'; +import { CloudPlugin, CloudConfigType } from './plugin'; +import { firstValueFrom } from 'rxjs'; +import { Sha256 } from '@kbn/core/public/utils'; describe('Cloud Plugin', () => { describe('#setup', () => { @@ -20,17 +20,7 @@ describe('Cloud Plugin', () => { jest.clearAllMocks(); }); - const setupPlugin = async ({ - config = {}, - securityEnabled = true, - currentUserProps = {}, - currentContext$ = undefined, - }: { - config?: Partial; - securityEnabled?: boolean; - currentUserProps?: Record; - currentContext$?: Observable; - }) => { + const setupPlugin = async ({ config = {} }: { config?: Partial }) => { const initContext = coreMock.createPluginInitializerContext({ id: 'cloudId', base_url: 'https://cloud.elastic.co', @@ -49,21 +39,9 @@ describe('Cloud Plugin', () => { const plugin = new CloudPlugin(initContext); const coreSetup = coreMock.createSetup(); - const coreStart = coreMock.createStart(); - if (currentContext$) { - coreStart.executionContext.context$ = currentContext$; - } - - coreSetup.getStartServices.mockResolvedValue([coreStart, {}, undefined]); - - const securitySetup = securityMock.createSetup(); - - securitySetup.authc.getCurrentUser.mockResolvedValue( - securityMock.createMockAuthenticatedUser(currentUserProps) - ); + const setup = plugin.setup(coreSetup, {}); - const setup = plugin.setup(coreSetup, securityEnabled ? { security: securitySetup } : {}); // Wait for FullStory dynamic import to resolve await new Promise((r) => setImmediate(r)); @@ -73,9 +51,6 @@ describe('Cloud Plugin', () => { test('register the shipper FullStory with correct args when enabled and org_id are set', async () => { const { coreSetup } = await setupPlugin({ config: { full_story: { enabled: true, org_id: 'foo' } }, - currentUserProps: { - username: '1234', - }, }); expect(coreSetup.analytics.registerShipper).toHaveBeenCalled(); @@ -86,12 +61,71 @@ describe('Cloud Plugin', () => { }); }); - test('register the context provider for the cloud user with hashed user ID when security is available', async () => { + it('does not call initializeFullStory when enabled=false', async () => { const { coreSetup } = await setupPlugin({ - config: { full_story: { enabled: true, org_id: 'foo' } }, - currentUserProps: { - username: '1234', + config: { full_story: { enabled: false, org_id: 'foo' } }, + }); + expect(coreSetup.analytics.registerShipper).not.toHaveBeenCalled(); + }); + + it('does not call initializeFullStory when org_id is undefined', async () => { + const { coreSetup } = await setupPlugin({ config: { full_story: { enabled: true } } }); + expect(coreSetup.analytics.registerShipper).not.toHaveBeenCalled(); + }); + }); + + describe('setupTelemetryContext', () => { + const username = '1234'; + const expectedHashedPlainUsername = new Sha256().update(username, 'utf8').digest('hex'); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const setupPlugin = async ({ + config = {}, + securityEnabled = true, + currentUserProps = {}, + }: { + config?: Partial; + securityEnabled?: boolean; + currentUserProps?: Record | Error; + }) => { + const initContext = coreMock.createPluginInitializerContext({ + base_url: 'https://cloud.elastic.co', + deployment_url: '/abc123', + profile_url: '/profile/alice', + organization_url: '/org/myOrg', + full_story: { + enabled: false, }, + chat: { + enabled: false, + }, + ...config, + }); + + const plugin = new CloudPlugin(initContext); + + const coreSetup = coreMock.createSetup(); + const securitySetup = securityMock.createSetup(); + if (currentUserProps instanceof Error) { + securitySetup.authc.getCurrentUser.mockRejectedValue(currentUserProps); + } else { + securitySetup.authc.getCurrentUser.mockResolvedValue( + securityMock.createMockAuthenticatedUser(currentUserProps) + ); + } + + const setup = plugin.setup(coreSetup, securityEnabled ? { security: securitySetup } : {}); + + return { initContext, plugin, setup, coreSetup }; + }; + + test('register the context provider for the cloud user with hashed user ID when security is available', async () => { + const { coreSetup } = await setupPlugin({ + config: { id: 'cloudId' }, + currentUserProps: { username }, }); expect(coreSetup.analytics.registerContextProvider).toHaveBeenCalled(); @@ -105,12 +139,10 @@ describe('Cloud Plugin', () => { }); }); - it('user hash includes org id', async () => { + it('user hash includes cloud id', async () => { const { coreSetup: coreSetup1 } = await setupPlugin({ - config: { full_story: { enabled: true, org_id: 'foo' }, id: 'esOrg1' }, - currentUserProps: { - username: '1234', - }, + config: { id: 'esOrg1' }, + currentUserProps: { username }, }); const [{ context$: context1$ }] = @@ -119,12 +151,11 @@ describe('Cloud Plugin', () => { )!; const hashId1 = await firstValueFrom(context1$); + expect(hashId1).not.toEqual(expectedHashedPlainUsername); const { coreSetup: coreSetup2 } = await setupPlugin({ config: { full_story: { enabled: true, org_id: 'foo' }, id: 'esOrg2' }, - currentUserProps: { - username: '1234', - }, + currentUserProps: { username }, }); const [{ context$: context2$ }] = @@ -133,150 +164,60 @@ describe('Cloud Plugin', () => { )!; const hashId2 = await firstValueFrom(context2$); + expect(hashId2).not.toEqual(expectedHashedPlainUsername); expect(hashId1).not.toEqual(hashId2); }); - it('emits the execution context provider everytime an app changes', async () => { - const currentContext$ = new Subject(); + test('user hash does not include cloudId when authenticated via Cloud SAML', async () => { const { coreSetup } = await setupPlugin({ - config: { full_story: { enabled: true, org_id: 'foo' } }, + config: { id: 'cloudDeploymentId' }, currentUserProps: { - username: '1234', + username, + authentication_realm: { type: 'saml', name: 'cloud-saml-kibana' }, }, - currentContext$, }); + expect(coreSetup.analytics.registerContextProvider).toHaveBeenCalled(); + const [{ context$ }] = coreSetup.analytics.registerContextProvider.mock.calls.find( - ([{ name }]) => name === 'execution_context' + ([{ name }]) => name === 'cloud_user_id' )!; - let latestContext; - context$.subscribe((context) => { - latestContext = context; - }); - - // takes the app name - expect(latestContext).toBeUndefined(); - currentContext$.next({ - name: 'App1', - description: '123', - }); - - await new Promise((r) => setImmediate(r)); - - expect(latestContext).toEqual({ - pageName: 'App1', - applicationId: 'App1', - }); - - // context clear - currentContext$.next({}); - expect(latestContext).toEqual({ - pageName: '', - applicationId: 'unknown', - }); - - // different app - currentContext$.next({ - name: 'App2', - page: 'page2', - id: '123', - }); - expect(latestContext).toEqual({ - pageName: 'App2:page2', - applicationId: 'App2', - page: 'page2', - entityId: '123', - }); - - // Back to first app - currentContext$.next({ - name: 'App1', - page: 'page3', - id: '123', - }); - - expect(latestContext).toEqual({ - pageName: 'App1:page3', - applicationId: 'App1', - page: 'page3', - entityId: '123', + await expect(firstValueFrom(context$)).resolves.toEqual({ + userId: expectedHashedPlainUsername, }); }); - it('does not register the cloud user id context provider when security is not available', async () => { + test('user hash does not include cloudId when not provided', async () => { const { coreSetup } = await setupPlugin({ - config: { full_story: { enabled: true, org_id: 'foo' } }, - securityEnabled: false, + config: {}, + currentUserProps: { username }, }); - expect( - coreSetup.analytics.registerContextProvider.mock.calls.find( - ([{ name }]) => name === 'cloud_user_id' - ) - ).toBeUndefined(); - }); - - describe('with memory', () => { - beforeAll(() => { - // @ts-expect-error 2339 - window.performance.memory = { - get jsHeapSizeLimit() { - return 3; - }, - get totalJSHeapSize() { - return 2; - }, - get usedJSHeapSize() { - return 1; - }, - }; - }); - - afterAll(() => { - // @ts-expect-error 2339 - delete window.performance.memory; - }); + expect(coreSetup.analytics.registerContextProvider).toHaveBeenCalled(); - it('reports an event when security is available', async () => { - const { initContext, coreSetup } = await setupPlugin({ - config: { full_story: { enabled: true, org_id: 'foo' } }, - currentUserProps: { - username: '1234', - }, - }); + const [{ context$ }] = coreSetup.analytics.registerContextProvider.mock.calls.find( + ([{ name }]) => name === 'cloud_user_id' + )!; - expect(coreSetup.analytics.reportEvent).toHaveBeenCalledWith('Loaded Kibana', { - kibana_version: initContext.env.packageInfo.version, - memory_js_heap_size_limit: 3, - memory_js_heap_size_total: 2, - memory_js_heap_size_used: 1, - }); + await expect(firstValueFrom(context$)).resolves.toEqual({ + userId: expectedHashedPlainUsername, }); }); - it('reports an event when security is not available', async () => { - const { initContext, coreSetup } = await setupPlugin({ - config: { full_story: { enabled: true, org_id: 'foo' } }, - securityEnabled: false, + test('user hash is undefined when failed to fetch a user', async () => { + const { coreSetup } = await setupPlugin({ + currentUserProps: new Error('failed to fetch a user'), }); - expect(coreSetup.analytics.reportEvent).toHaveBeenCalledWith('Loaded Kibana', { - kibana_version: initContext.env.packageInfo.version, - }); - }); + expect(coreSetup.analytics.registerContextProvider).toHaveBeenCalled(); - it('does not call initializeFullStory when enabled=false', async () => { - const { coreSetup } = await setupPlugin({ - config: { full_story: { enabled: false, org_id: 'foo' } }, - }); - expect(coreSetup.analytics.registerShipper).not.toHaveBeenCalled(); - }); + const [{ context$ }] = coreSetup.analytics.registerContextProvider.mock.calls.find( + ([{ name }]) => name === 'cloud_user_id' + )!; - it('does not call initializeFullStory when org_id is undefined', async () => { - const { coreSetup } = await setupPlugin({ config: { full_story: { enabled: true } } }); - expect(coreSetup.analytics.registerShipper).not.toHaveBeenCalled(); + await expect(firstValueFrom(context$)).resolves.toEqual({ userId: undefined }); }); }); @@ -652,56 +593,4 @@ describe('Cloud Plugin', () => { expect(securityStart.navControlService.addUserMenuLinks).not.toHaveBeenCalled(); }); }); - - describe('loadFullStoryUserId', () => { - let consoleMock: jest.SpyInstance; - - beforeEach(() => { - consoleMock = jest.spyOn(console, 'debug').mockImplementation(() => {}); - }); - afterEach(() => { - consoleMock.mockRestore(); - }); - - it('returns principal ID when username specified', async () => { - expect( - await loadUserId({ - getCurrentUser: jest.fn().mockResolvedValue({ - username: '1234', - }), - }) - ).toEqual('1234'); - expect(consoleMock).not.toHaveBeenCalled(); - }); - - it('returns undefined if getCurrentUser throws', async () => { - expect( - await loadUserId({ - getCurrentUser: jest.fn().mockRejectedValue(new Error(`Oh no!`)), - }) - ).toBeUndefined(); - }); - - it('returns undefined if getCurrentUser returns undefined', async () => { - expect( - await loadUserId({ - getCurrentUser: jest.fn().mockResolvedValue(undefined), - }) - ).toBeUndefined(); - }); - - it('returns undefined and logs if username undefined', async () => { - expect( - await loadUserId({ - getCurrentUser: jest.fn().mockResolvedValue({ - username: undefined, - metadata: { foo: 'bar' }, - }), - }) - ).toBeUndefined(); - expect(consoleMock).toHaveBeenLastCalledWith( - `[cloud.analytics] username not specified. User metadata: {"foo":"bar"}` - ); - }); - }); }); diff --git a/x-pack/plugins/cloud/public/plugin.tsx b/x-pack/plugins/cloud/public/plugin.tsx index 4ee3098c709cfe3..1bccf219225dc7c 100644 --- a/x-pack/plugins/cloud/public/plugin.tsx +++ b/x-pack/plugins/cloud/public/plugin.tsx @@ -13,21 +13,16 @@ import type { PluginInitializerContext, HttpStart, IBasePath, - ExecutionContextStart, AnalyticsServiceSetup, } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import useObservable from 'react-use/lib/useObservable'; -import { BehaviorSubject, from, of, Subscription } from 'rxjs'; -import { exhaustMap, filter, map } from 'rxjs/operators'; -import { compact } from 'lodash'; +import { BehaviorSubject, catchError, from, map, of } from 'rxjs'; -import type { - AuthenticatedUser, - SecurityPluginSetup, - SecurityPluginStart, -} from '@kbn/security-plugin/public'; +import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public'; import { HomePublicPluginSetup } from '@kbn/home-plugin/public'; +import { Sha256 } from '@kbn/core/public/utils'; +import { registerCloudDeploymentIdAnalyticsContext } from '../common/register_cloud_deployment_id_analytics_context'; import { getIsCloudEnabled } from '../common/is_cloud_enabled'; import { ELASTIC_SUPPORT_LINK, @@ -91,11 +86,6 @@ interface SetupFullStoryDeps { analytics: AnalyticsServiceSetup; basePath: IBasePath; } -interface SetupTelemetryContextDeps extends CloudSetupDependencies { - analytics: AnalyticsServiceSetup; - executionContextPromise: Promise; - cloudId?: string; -} interface SetupChatDeps extends Pick { http: CoreSetup['http']; @@ -104,7 +94,6 @@ interface SetupChatDeps extends Pick { export class CloudPlugin implements Plugin { private readonly config: CloudConfigType; private isCloudEnabled: boolean; - private appSubscription?: Subscription; private chatConfig$ = new BehaviorSubject({ enabled: false }); constructor(private readonly initializerContext: PluginInitializerContext) { @@ -113,19 +102,7 @@ export class CloudPlugin implements Plugin { } public setup(core: CoreSetup, { home, security }: CloudSetupDependencies) { - const executionContextPromise = core.getStartServices().then(([coreStart]) => { - return coreStart.executionContext; - }); - - this.setupTelemetryContext({ - analytics: core.analytics, - security, - executionContextPromise, - cloudId: this.config.id, - }).catch((e) => { - // eslint-disable-next-line no-console - console.debug(`Error setting up TelemetryContext: ${e.toString()}`); - }); + this.setupTelemetryContext(core.analytics, security, this.config.id); this.setupFullStory({ analytics: core.analytics, basePath: core.http.basePath }).catch((e) => // eslint-disable-next-line no-console @@ -213,9 +190,7 @@ export class CloudPlugin implements Plugin { }; } - public stop() { - this.appSubscription?.unsubscribe(); - } + public stop() {} /** * Determines if the current user should see links back to Cloud. @@ -272,48 +247,36 @@ export class CloudPlugin implements Plugin { * Set up the Analytics context providers. * @param analytics Core's Analytics service. The Setup contract. * @param security The security plugin. - * @param executionContextPromise Core's executionContext's start contract. - * @param esOrgId The Cloud Org ID. + * @param cloudId The Cloud Org ID. * @private */ - private async setupTelemetryContext({ - analytics, - security, - executionContextPromise, - cloudId, - }: SetupTelemetryContextDeps) { - // Some context providers can be moved to other places for better domain isolation. - // Let's use https://github.com/elastic/kibana/issues/125690 for that purpose. - analytics.registerContextProvider({ - name: 'kibana_version', - context$: of({ version: this.initializerContext.env.packageInfo.version }), - schema: { version: { type: 'keyword', _meta: { description: 'The version of Kibana' } } }, - }); + private setupTelemetryContext( + analytics: AnalyticsServiceSetup, + security?: Pick, + cloudId?: string + ) { + registerCloudDeploymentIdAnalyticsContext(analytics, cloudId); - analytics.registerContextProvider({ - name: 'cloud_org_id', - context$: of({ cloudId }), - schema: { - cloudId: { - type: 'keyword', - _meta: { description: 'The Cloud ID', optional: true }, - }, - }, - }); - - // This needs to be called synchronously to be sure that we populate the user ID soon enough to make sessions merging - // across domains work if (security) { analytics.registerContextProvider({ name: 'cloud_user_id', - context$: from(loadUserId({ getCurrentUser: security.authc.getCurrentUser })).pipe( - filter((userId): userId is string => Boolean(userId)), - exhaustMap(async (userId) => { - const { sha256 } = await import('js-sha256'); - // Join the cloud org id and the user to create a truly unique user id. - // The hashing here is to keep it at clear as possible in our source code that we do not send literal user IDs - return { userId: sha256(cloudId ? `${cloudId}:${userId}` : `${userId}`) }; - }) + context$: from(security.authc.getCurrentUser()).pipe( + map((user) => { + if ( + getIsCloudEnabled(cloudId) && + user.authentication_realm?.type === 'saml' && + user.authentication_realm?.name === 'cloud-saml-kibana' + ) { + // If authenticated via Cloud SAML, use the SAML username as the user ID + return user.username; + } + + return cloudId ? `${cloudId}:${user.username}` : user.username; + }), + // Join the cloud org id and the user to create a truly unique user id. + // The hashing here is to keep it at clear as possible in our source code that we do not send literal user IDs + map((userId) => ({ userId: sha256(userId) })), + catchError(() => of({ userId: undefined })) ), schema: { userId: { @@ -323,81 +286,6 @@ export class CloudPlugin implements Plugin { }, }); } - - const executionContext = await executionContextPromise; - analytics.registerContextProvider({ - name: 'execution_context', - context$: executionContext.context$.pipe( - // Update the current context every time it changes - map(({ name, page, id }) => ({ - pageName: `${compact([name, page]).join(':')}`, - applicationId: name ?? 'unknown', - page, - entityId: id, - })) - ), - schema: { - pageName: { - type: 'keyword', - _meta: { description: 'The name of the current page' }, - }, - page: { - type: 'keyword', - _meta: { description: 'The current page', optional: true }, - }, - applicationId: { - type: 'keyword', - _meta: { description: 'The id of the current application' }, - }, - entityId: { - type: 'keyword', - _meta: { - description: - 'The id of the current entity (dashboard, visualization, canvas, lens, etc)', - optional: true, - }, - }, - }, - }); - - analytics.registerEventType({ - eventType: 'Loaded Kibana', - schema: { - kibana_version: { - type: 'keyword', - _meta: { description: 'The version of Kibana', optional: true }, - }, - memory_js_heap_size_limit: { - type: 'long', - _meta: { description: 'The maximum size of the heap', optional: true }, - }, - memory_js_heap_size_total: { - type: 'long', - _meta: { description: 'The total size of the heap', optional: true }, - }, - memory_js_heap_size_used: { - type: 'long', - _meta: { description: 'The used size of the heap', optional: true }, - }, - }, - }); - - // Get performance information from the browser (non standard property - // @ts-expect-error 2339 - const memory = window.performance.memory; - let memoryInfo = {}; - if (memory) { - memoryInfo = { - memory_js_heap_size_limit: memory.jsHeapSizeLimit, - memory_js_heap_size_total: memory.totalJSHeapSize, - memory_js_heap_size_used: memory.usedJSHeapSize, - }; - } - - analytics.reportEvent('Loaded Kibana', { - kibana_version: this.initializerContext.env.packageInfo.version, - ...memoryInfo, - }); } private async setupChat({ http, security }: SetupChatDeps) { @@ -438,32 +326,6 @@ export class CloudPlugin implements Plugin { } } -/** @internal exported for testing */ -export const loadUserId = async ({ - getCurrentUser, -}: { - getCurrentUser: () => Promise; -}) => { - try { - const currentUser = await getCurrentUser().catch(() => undefined); - if (!currentUser) { - return undefined; - } - - // Log very defensively here so we can debug this easily if it breaks - if (!currentUser.username) { - // eslint-disable-next-line no-console - console.debug( - `[cloud.analytics] username not specified. User metadata: ${JSON.stringify( - currentUser.metadata - )}` - ); - } - - return currentUser.username; - } catch (e) { - // eslint-disable-next-line no-console - console.error(`[cloud.analytics] Error loading the current user: ${e.toString()}`, e); - return undefined; - } -}; +function sha256(str: string) { + return new Sha256().update(str, 'utf8').digest('hex'); +} diff --git a/x-pack/plugins/cloud/server/plugin.ts b/x-pack/plugins/cloud/server/plugin.ts index 284d37804be2129..2cbb41531ecf54f 100644 --- a/x-pack/plugins/cloud/server/plugin.ts +++ b/x-pack/plugins/cloud/server/plugin.ts @@ -8,6 +8,7 @@ import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; import { CoreSetup, Logger, Plugin, PluginInitializerContext } from '@kbn/core/server'; import type { SecurityPluginSetup } from '@kbn/security-plugin/server'; +import { registerCloudDeploymentIdAnalyticsContext } from '../common/register_cloud_deployment_id_analytics_context'; import { CloudConfigType } from './config'; import { registerCloudUsageCollector } from './collectors'; import { getIsCloudEnabled } from '../common/is_cloud_enabled'; @@ -35,7 +36,7 @@ export interface CloudSetup { export class CloudPlugin implements Plugin { private readonly logger: Logger; private readonly config: CloudConfigType; - private isDev: boolean; + private readonly isDev: boolean; constructor(private readonly context: PluginInitializerContext) { this.logger = this.context.logger.get(); @@ -46,6 +47,7 @@ export class CloudPlugin implements Plugin { public setup(core: CoreSetup, { usageCollection, security }: PluginsSetup): CloudSetup { this.logger.debug('Setting up Cloud plugin'); const isCloudEnabled = getIsCloudEnabled(this.config.id); + registerCloudDeploymentIdAnalyticsContext(core.analytics, this.config.id); registerCloudUsageCollector(usageCollection, { isCloudEnabled }); if (this.config.full_story.enabled) { diff --git a/x-pack/plugins/cloud_security_posture/common/constants.ts b/x-pack/plugins/cloud_security_posture/common/constants.ts index b2edd268c848507..360827f7855f075 100644 --- a/x-pack/plugins/cloud_security_posture/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/common/constants.ts @@ -30,8 +30,7 @@ export const RULE_FAILED = `failed`; export const INTERNAL_FEATURE_FLAGS = { showBenchmarks: true, showManageRulesMock: false, - showRisksMock: false, - showFindingsGroupBy: false, + showFindingsGroupBy: true, } as const; export const cspRuleAssetSavedObjectType = 'csp_rule'; diff --git a/x-pack/plugins/cloud_security_posture/common/schemas/csp_configuration.ts b/x-pack/plugins/cloud_security_posture/common/schemas/csp_configuration.ts index f5d38e938e2ccd6..a796ace382d1316 100644 --- a/x-pack/plugins/cloud_security_posture/common/schemas/csp_configuration.ts +++ b/x-pack/plugins/cloud_security_posture/common/schemas/csp_configuration.ts @@ -7,8 +7,10 @@ import { schema as rt, TypeOf } from '@kbn/config-schema'; export const cspRulesConfigSchema = rt.object({ - activated_rules: rt.object({ - cis_k8s: rt.arrayOf(rt.string()), + data_yaml: rt.object({ + activated_rules: rt.object({ + cis_k8s: rt.arrayOf(rt.string()), + }), }), }); diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_url_query.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_url_query.ts index 2eea943f757cfc8..0573d77e6f9c82f 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_url_query.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_url_query.ts @@ -33,7 +33,6 @@ export const useUrlQuery = (getDefaultQuery: () => T) => { // Set initial query useEffect(() => { - // TODO: condition should be if decoding failed if (search) return; replace({ search: encodeQuery(getDefaultQuery() as RisonObject) }); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.tsx index b6b309cadbb0299..16abf969f3062bb 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_charts/risks_table.tsx @@ -8,6 +8,7 @@ import React, { useMemo } from 'react'; import { EuiBasicTable, + EuiBasicTableColumn, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, @@ -17,46 +18,6 @@ import { import { ComplianceDashboardData, GroupedFindingsEvaluation } from '../../../../common/types'; import { CompactFormattedNumber } from '../../../components/compact_formatted_number'; import * as TEXT from '../translations'; -import { INTERNAL_FEATURE_FLAGS } from '../../../../common/constants'; - -const mockData = [ - { - name: 'pods', - totalFindings: 2, - totalPassed: 1, - totalFailed: 1, - }, - { - name: 'etcd', - totalFindings: 5, - totalPassed: 0, - totalFailed: 5, - }, - { - name: 'cluster', - totalFindings: 2, - totalPassed: 2, - totalFailed: 0, - }, - { - name: 'system', - totalFindings: 10, - totalPassed: 6, - totalFailed: 4, - }, - { - name: 'api', - totalFindings: 19100, - totalPassed: 2100, - totalFailed: 17000, - }, - { - name: 'server', - totalFindings: 7, - totalPassed: 4, - totalFailed: 3, - }, -]; export interface RisksTableProps { data: ComplianceDashboardData['groupedFindingsEvaluation']; @@ -81,13 +42,16 @@ export const RisksTable = ({ onCellClick, onViewAllClick, }: RisksTableProps) => { - const columns = useMemo( + const columns: Array> = useMemo( () => [ { field: 'name', + truncateText: true, name: TEXT.CIS_SECTION, render: (name: GroupedFindingsEvaluation['name']) => ( - onCellClick(name)}>{name} + onCellClick(name)} className="eui-textTruncate"> + {name} + ), }, { @@ -119,7 +83,7 @@ export const RisksTable = ({ rowHeader="name" - items={INTERNAL_FEATURE_FLAGS.showRisksMock ? getTopRisks(mockData, maxItems) : items} + items={items} columns={columns} /> diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/findings_flyout.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/findings_flyout.tsx index be05e2b8418d624..feec289432a684e 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/findings_flyout.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/findings_flyout.tsx @@ -9,7 +9,6 @@ import { EuiCodeBlock, EuiFlexItem, EuiSpacer, - EuiDescriptionList, EuiTextColor, EuiFlyout, EuiFlyoutHeader, @@ -17,80 +16,49 @@ import { EuiFlyoutBody, EuiTabs, EuiTab, - EuiFlexGrid, - EuiCard, EuiFlexGroup, - EuiIcon, type PropsOf, EuiMarkdownFormat, } from '@elastic/eui'; import { assertNever } from '@kbn/std'; -import moment from 'moment'; import type { CspFinding } from '../types'; import { CspEvaluationBadge } from '../../../components/csp_evaluation_badge'; import * as TEXT from '../translations'; -import cisLogoIcon from '../../../assets/icons/cis_logo.svg'; -import k8sLogoIcon from '../../../assets/icons/k8s_logo.svg'; import { ResourceTab } from './resource_tab'; import { JsonTab } from './json_tab'; +import { OverviewTab } from './overview_tab'; +import { RuleTab } from './rule_tab'; const tabs = [ - { title: TEXT.REMEDIATION, id: 'remediation' }, + { title: TEXT.OVERVIEW, id: 'overview' }, + { title: TEXT.RULE, id: 'rule' }, { title: TEXT.RESOURCE, id: 'resource' }, - { title: TEXT.GENERAL, id: 'general' }, { title: TEXT.JSON, id: 'json' }, ] as const; -const CodeBlock: React.FC> = (props) => ( +export const CodeBlock: React.FC> = (props) => ( ); -const Markdown: React.FC> = (props) => ( +export const Markdown: React.FC> = (props) => ( ); type FindingsTab = typeof tabs[number]; -type EuiListItemsProps = NonNullable['listItems']>[number]; - -interface Card { - title: string; - listItems: Array<[EuiListItemsProps['title'], EuiListItemsProps['description']]>; -} - interface FindingFlyoutProps { onClose(): void; findings: CspFinding; } -const Cards = ({ data }: { data: Card[] }) => ( - - {data.map((card) => ( - - - ({ title: v[0], description: v[1] }))} - style={{ flexFlow: 'column' }} - descriptionProps={{ - style: { width: '100%' }, - }} - /> - - - ))} - -); - const FindingsTab = ({ tab, findings }: { findings: CspFinding; tab: FindingsTab }) => { switch (tab.id) { - case 'remediation': - return ; + case 'overview': + return ; + case 'rule': + return ; case 'resource': return ; - case 'general': - return ; case 'json': return ; default: @@ -131,55 +99,3 @@ export const FindingsRuleFlyout = ({ onClose, findings }: FindingFlyoutProps) => ); }; - -const getGeneralCards = ({ rule, ...rest }: CspFinding): Card[] => [ - { - title: TEXT.RULE, - listItems: [ - [TEXT.RULE_EVALUATED_AT, moment(rest['@timestamp']).format('MMMM D, YYYY @ HH:mm:ss.SSS')], - [ - TEXT.FRAMEWORK_SOURCES, - - - - - - - - , - ], - [TEXT.CIS_SECTION, rule.section], - [TEXT.PROFILE_APPLICABILITY, {rule.profile_applicability}], - [TEXT.BENCHMARK, rule.benchmark.name], - [TEXT.NAME, rule.name], - [TEXT.DESCRIPTION, {rule.description}], - [TEXT.AUDIT, {rule.audit}], - [TEXT.REFERENCES, {rule.references}], - ], - }, -]; - -const getRemediationCards = ({ result, rule, ...rest }: CspFinding): Card[] => [ - { - title: TEXT.RESULT_DETAILS, - listItems: [ - result.expected - ? [TEXT.EXPECTED, {JSON.stringify(result.expected, null, 2)}] - : ['', ''], - [TEXT.EVIDENCE, {JSON.stringify(result.evidence, null, 2)}], - [ - TEXT.RULE_EVALUATED_AT, - {moment(rest['@timestamp']).format('MMMM D, YYYY @ HH:mm:ss.SSS')}, - ], - ], - }, - { - title: TEXT.REMEDIATION, - listItems: [ - ['', {rule.remediation}], - [TEXT.IMPACT, {rule.impact}], - [TEXT.DEFAULT_VALUE, {rule.default_value}], - [TEXT.RATIONALE, {rule.rationale}], - ], - }, -]; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/overview_tab.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/overview_tab.tsx new file mode 100644 index 000000000000000..679aea94935d86d --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/overview_tab.tsx @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiAccordion, + EuiDescriptionList, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPanel, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import React, { useMemo } from 'react'; +import moment from 'moment'; +import type { EuiDescriptionListProps, EuiAccordionProps } from '@elastic/eui'; +import cisLogoIcon from '../../../assets/icons/cis_logo.svg'; +import k8sLogoIcon from '../../../assets/icons/k8s_logo.svg'; +import * as TEXT from '../translations'; +import { CspFinding } from '../types'; +import { CodeBlock, Markdown } from './findings_flyout'; + +type Accordion = Pick & + Pick; + +const getDetailsList = (data: CspFinding) => [ + { + title: TEXT.RULE_NAME, + description: data.rule.name, + }, + { + title: TEXT.EVALUATED_AT, + description: moment(data['@timestamp']).format('MMMM D, YYYY @ HH:mm:ss.SSS'), + }, + { + title: TEXT.RESOURCE_NAME, + description: data.resource.name, + }, + { + title: TEXT.FRAMEWORK_SOURCES, + description: ( + + + + + + + + + ), + }, + { + title: TEXT.CIS_SECTION, + description: data.rule.section, + }, +]; + +const getRemediationList = ({ rule }: CspFinding) => [ + { + title: '', + description: {rule.remediation}, + }, + { + title: TEXT.IMPACT, + description: {rule.impact}, + }, + { + title: TEXT.DEFAULT_VALUE, + description: {rule.default_value}, + }, + { + title: TEXT.RATIONALE, + description: {rule.rationale}, + }, +]; + +const getEvidenceList = ({ result }: CspFinding) => + [ + result.expected && { + title: TEXT.EXPECTED, + description: {JSON.stringify(result.expected, null, 2)}, + }, + { + title: TEXT.ACTUAL, + description: {JSON.stringify(result.evidence, null, 2)}, + }, + ].filter(Boolean) as EuiDescriptionListProps['listItems']; + +export const OverviewTab = ({ data }: { data: CspFinding }) => { + const accordions: Accordion[] = useMemo( + () => [ + { + initialIsOpen: true, + title: TEXT.DETAILS, + id: 'detailsAccordion', + listItems: getDetailsList(data), + }, + { + initialIsOpen: true, + title: TEXT.REMEDIATION, + id: 'remediationAccordion', + listItems: getRemediationList(data), + }, + { + initialIsOpen: false, + title: TEXT.EVIDENCE, + id: 'evidenceAccordion', + listItems: getEvidenceList(data), + }, + ], + [data] + ); + + return ( + <> + {accordions.map((accordion) => ( + + + + {accordion.title} + + } + arrowDisplay="left" + initialIsOpen={accordion.initialIsOpen} + > + + + + + + + ))} + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/resource_tab.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/resource_tab.tsx index 7919b836a3a7384..4ab801213a66dfb 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/resource_tab.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_flyout/resource_tab.tsx @@ -80,7 +80,7 @@ export const ResourceTab = ({ data }: { data: CspFinding }) => { {accordion.title} } - arrowDisplay="right" + arrowDisplay="left" initialIsOpen > [ + { + title: TEXT.NAME, + description: rule.name, + }, + { + title: TEXT.DESCRIPTION, + description: {rule.description}, + }, + { + title: TEXT.FRAMEWORK_SOURCES, + description: ( + + + + + + + + + ), + }, + { + title: TEXT.CIS_SECTION, + description: rule.section, + }, + { + title: TEXT.PROFILE_APPLICABILITY, + description: {rule.profile_applicability}, + }, + { + title: TEXT.BENCHMARK, + description: rule.benchmark.name, + }, + + { + title: TEXT.AUDIT, + description: {rule.audit}, + }, + { + title: TEXT.REFERENCES, + description: {rule.references}, + }, +]; + +export const RuleTab = ({ data }: { data: CspFinding }) => ( + +); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_container.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_container.test.tsx index ae980c1e492bba3..29c9df5f4a93247 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_container.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_container.test.tsx @@ -18,6 +18,7 @@ import { useLocation } from 'react-router-dom'; import { RisonObject } from 'rison-node'; import { buildEsQuery } from '@kbn/es-query'; import { getFindingsCountAggQuery } from '../use_findings_count'; +import { getPaginationQuery } from '../utils'; jest.mock('../../../common/api/use_latest_findings_data_view'); jest.mock('../../../common/api/use_cis_kubernetes_integration'); @@ -69,9 +70,8 @@ describe('', () => { expect(dataMock.search.search).toHaveBeenNthCalledWith(2, { params: getFindingsQuery({ ...baseQuery, + ...getPaginationQuery(query), sort: query.sort, - size: query.size, - from: query.from, }), }); }); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_container.tsx index 78a1fd758b6eef8..a013c3ef05f1293 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_container.tsx @@ -7,8 +7,8 @@ import React, { useMemo } from 'react'; import { EuiSpacer } from '@elastic/eui'; import type { DataView } from '@kbn/data-plugin/common'; -import { SortDirection } from '@kbn/data-plugin/common'; import { FormattedMessage } from '@kbn/i18n-react'; +import { number } from 'io-ts'; import { FindingsTable } from './latest_findings_table'; import { FindingsSearchBar } from '../layout/findings_search_bar'; import * as TEST_SUBJECTS from '../test_subjects'; @@ -18,7 +18,7 @@ import type { FindingsGroupByNoneQuery } from './use_latest_findings'; import type { FindingsBaseURLQuery } from '../types'; import { useFindingsCounter } from '../use_findings_count'; import { FindingsDistributionBar } from '../layout/findings_distribution_bar'; -import { getBaseQuery } from '../utils'; +import { getBaseQuery, getPaginationQuery, getPaginationTableParams } from '../utils'; import { PageWrapper, PageTitle, PageTitleText } from '../layout/findings_layout'; import { FindingsGroupBySelector } from '../layout/findings_group_by_selector'; import { useCspBreadcrumbs } from '../../../common/navigation/use_csp_breadcrumbs'; @@ -27,14 +27,15 @@ import { findingsNavigation } from '../../../common/navigation/constants'; export const getDefaultQuery = (): FindingsBaseURLQuery & FindingsGroupByNoneQuery => ({ query: { language: 'kuery', query: '' }, filters: [], - sort: [{ ['@timestamp']: SortDirection.desc }], - from: 0, - size: 10, + sort: { field: '@timestamp', direction: 'desc' }, + pageIndex: 0, + pageSize: 10, }); export const LatestFindingsContainer = ({ dataView }: { dataView: DataView }) => { useCspBreadcrumbs([findingsNavigation.findings_default]); const { urlQuery, setUrlQuery } = useUrlQuery(getDefaultQuery); + const baseEsQuery = useMemo( () => getBaseQuery({ dataView, filters: urlQuery.filters, query: urlQuery.query }), [dataView, urlQuery.filters, urlQuery.query] @@ -43,11 +44,19 @@ export const LatestFindingsContainer = ({ dataView }: { dataView: DataView }) => const findingsCount = useFindingsCounter(baseEsQuery); const findingsGroupByNone = useLatestFindings({ ...baseEsQuery, - size: urlQuery.size, - from: urlQuery.from, + ...getPaginationQuery({ pageIndex: urlQuery.pageIndex, pageSize: urlQuery.pageSize }), sort: urlQuery.sort, }); + const findingsDistribution = getFindingsDistribution({ + total: findingsGroupByNone.data?.total, + passed: findingsCount.data?.passed, + failed: findingsCount.data?.failed, + pageIndex: urlQuery.pageIndex, + pageSize: urlQuery.pageSize, + currentPageSize: findingsGroupByNone.data?.page.length, + }); + return (
    setQuery={setUrlQuery} query={urlQuery.query} filters={urlQuery.filters} - loading={findingsGroupByNone.isLoading} + loading={findingsCount.isFetching} /> - - - } - /> - + - + {findingsDistribution && } + setUrlQuery({ pageIndex: page.index, pageSize: page.size, sort }) + } />
    ); }; + +const LatestFindingsPageTitle = () => ( + + } + /> + +); + +const getFindingsDistribution = ({ + total, + passed, + failed, + currentPageSize, + pageIndex, + pageSize, +}: Record<'currentPageSize' | 'total' | 'passed' | 'failed', number | undefined> & + Record<'pageIndex' | 'pageSize', number>) => { + if (!number.is(total) || !number.is(passed) || !number.is(failed) || !number.is(currentPageSize)) + return; + + return { + total, + passed, + failed, + pageStart: pageIndex * pageSize + 1, + pageEnd: pageIndex * pageSize + currentPageSize, + }; +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_table.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_table.test.tsx index d01af2fa96e94b1..be2644be4bcdd9f 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_table.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_table.test.tsx @@ -50,6 +50,7 @@ const getFakeFindings = (name: string): CspFinding & { id: string } => ({ version: chance.string(), }, resource: { + name: chance.string(), filename: chance.string(), type: chance.string(), path: chance.string(), @@ -70,10 +71,9 @@ describe('', () => { loading: false, data: { page: [], total: 0 }, error: null, - sort: [], - from: 1, - size: 10, - setQuery: jest.fn, + sorting: { sort: { field: '@timestamp', direction: 'desc' } }, + pagination: { pageSize: 10, pageIndex: 1, totalItemCount: 0 }, + setTableOptions: jest.fn(), }; render( @@ -93,10 +93,9 @@ describe('', () => { loading: false, data: { page: data, total: 10 }, error: null, - sort: [], - from: 0, - size: 10, - setQuery: jest.fn, + sorting: { sort: { field: '@timestamp', direction: 'desc' } }, + pagination: { pageSize: 10, pageIndex: 1, totalItemCount: 0 }, + setTableOptions: jest.fn(), }; render( diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_table.tsx index 6a2bd1c129b5034..784bd3fa507671d 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_table.tsx @@ -4,38 +4,41 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { - type Criteria, EuiEmptyPrompt, EuiBasicTable, - EuiBasicTableProps, EuiBasicTableColumn, + type Pagination, + type EuiBasicTableProps, + type CriteriaWithPagination, + type EuiTableActionsColumnType, } from '@elastic/eui'; -import { SortDirection } from '@kbn/data-plugin/common'; -import { EuiTableActionsColumnType } from '@elastic/eui/src/components/basic_table/table_types'; import { extractErrorMessage } from '../../../../common/utils/helpers'; import * as TEST_SUBJECTS from '../test_subjects'; import * as TEXT from '../translations'; import type { CspFinding } from '../types'; -import type { FindingsGroupByNoneQuery, CspFindingsResult } from './use_latest_findings'; +import type { CspFindingsResult } from './use_latest_findings'; import { FindingsRuleFlyout } from '../findings_flyout/findings_flyout'; import { getExpandColumn, getFindingsColumns } from '../layout/findings_layout'; -interface BaseFindingsTableProps extends FindingsGroupByNoneQuery { - setQuery(query: Partial): void; +type TableProps = Required>; + +interface BaseFindingsTableProps { + pagination: Pagination; + sorting: TableProps['sorting']; + setTableOptions(options: CriteriaWithPagination): void; } type FindingsTableProps = CspFindingsResult & BaseFindingsTableProps; const FindingsTableComponent = ({ - setQuery, - from, - size, - sort = [], error, data, loading, + pagination, + sorting, + setTableOptions, }: FindingsTableProps) => { const [selectedFinding, setSelectedFinding] = useState(); @@ -47,25 +50,6 @@ const FindingsTableComponent = ({ [] ); - const pagination = useMemo( - () => - getEuiPaginationFromEsSearchSource({ - from, - size, - total: data?.total, - }), - [from, size, data] - ); - - const sorting = useMemo(() => getEuiSortFromEsSearchSource(sort), [sort]); - - const onTableChange = useCallback( - (params: Criteria) => { - setQuery(getEsSearchQueryFromEuiTableParams(params)); - }, - [setQuery] - ); - // Show "zero state" if (!loading && !data?.page.length) // TODO: use our own logo @@ -87,7 +71,7 @@ const FindingsTableComponent = ({ columns={columns} pagination={pagination} sorting={sorting} - onChange={onTableChange} + onChange={setTableOptions} hasActions /> {selectedFinding && ( @@ -100,38 +84,4 @@ const FindingsTableComponent = ({ ); }; -const getEuiPaginationFromEsSearchSource = ({ - from: pageIndex, - size: pageSize, - total, -}: Pick & { - total: number | undefined; -}): EuiBasicTableProps['pagination'] => ({ - pageSize, - pageIndex: Math.ceil(pageIndex / pageSize), - totalItemCount: total || 0, - pageSizeOptions: [10, 25, 100], - showPerPageOptions: true, -}); - -const getEuiSortFromEsSearchSource = ( - sort: FindingsGroupByNoneQuery['sort'] -): EuiBasicTableProps['sorting'] => { - if (!sort.length) return; - - const entry = Object.entries(sort[0])?.[0]; - if (!entry) return; - - const [field, direction] = entry; - return { sort: { field: field as keyof CspFinding, direction: direction as SortDirection } }; -}; - -const getEsSearchQueryFromEuiTableParams = ({ - page, - sort, -}: Criteria): Partial => ({ - ...(!!page && { from: page.index * page.size, size: page.size }), - sort: sort ? [{ [sort.field]: SortDirection[sort.direction] }] : undefined, -}); - export const FindingsTable = React.memo(FindingsTableComponent); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/use_latest_findings.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/use_latest_findings.ts index 608f400953c86ff..e7f9655849be018 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/use_latest_findings.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/use_latest_findings.ts @@ -7,8 +7,9 @@ import { useQuery } from 'react-query'; import { number } from 'io-ts'; import { lastValueFrom } from 'rxjs'; -import type { EsQuerySortValue, IEsSearchResponse } from '@kbn/data-plugin/common'; +import type { IEsSearchResponse } from '@kbn/data-plugin/common'; import type { CoreStart } from '@kbn/core/public'; +import type { Criteria, Pagination } from '@elastic/eui'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { extractErrorMessage } from '../../../../common/utils/helpers'; import * as TEXT from '../translations'; @@ -16,12 +17,18 @@ import type { CspFinding, FindingsQueryResult } from '../types'; import { useKibana } from '../../../common/hooks/use_kibana'; import type { FindingsBaseEsQuery } from '../types'; -interface UseFindingsOptions extends FindingsBaseEsQuery, FindingsGroupByNoneQuery {} - -export interface FindingsGroupByNoneQuery { +interface UseFindingsOptions extends FindingsBaseEsQuery { from: NonNullable; size: NonNullable; - sort: EsQuerySortValue[]; + sort: Sort; +} + +type Sort = NonNullable['sort']>; + +export interface FindingsGroupByNoneQuery { + pageIndex: Pagination['pageIndex']; + pageSize: Pagination['pageSize']; + sort: Sort; } interface CspFindingsData { @@ -37,23 +44,6 @@ const FIELDS_WITHOUT_KEYWORD_MAPPING = new Set(['@timestamp']); const getSortKey = (key: string): string => FIELDS_WITHOUT_KEYWORD_MAPPING.has(key) ? key : `${key}.keyword`; -/** - * @description utility to transform a column header key to its field mapping for sorting - * @example Adds '.keyword' to every property we sort on except values of `FIELDS_WITHOUT_KEYWORD_MAPPING` - * @todo find alternative - * @note we choose the keyword 'keyword' in the field mapping - */ -const mapEsQuerySortKey = (sort: readonly EsQuerySortValue[]): EsQuerySortValue[] => - sort.slice().reduce((acc, cur) => { - const entry = Object.entries(cur)[0]; - if (!entry) return acc; - - const [k, v] = entry; - acc.push({ [getSortKey(k)]: v }); - - return acc; - }, []); - export const showErrorToast = ( toasts: CoreStart['notifications']['toasts'], error: unknown @@ -67,7 +57,7 @@ export const getFindingsQuery = ({ index, query, size, from, sort }: UseFindings query, size, from, - sort: mapEsQuerySortKey(sort), + sort: [{ [getSortKey(sort.field)]: sort.direction }], }); export const useLatestFindings = ({ index, query, sort, from, size }: UseFindingsOptions) => { @@ -85,6 +75,7 @@ export const useLatestFindings = ({ index, query, sort, from, size }: UseFinding }) ), { + keepPreviousData: true, select: ({ rawResponse: { hits } }) => ({ page: hits.hits.map((hit) => hit._source!), total: number.is(hits.total) ? hits.total : 0, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/resource_findings_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/resource_findings_container.tsx index a48926b3653aa50..e1794a92d7b920b 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/resource_findings_container.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/resource_findings_container.tsx @@ -15,30 +15,30 @@ import * as TEST_SUBJECTS from '../../test_subjects'; import { PageWrapper, PageTitle, PageTitleText } from '../../layout/findings_layout'; import { useCspBreadcrumbs } from '../../../../common/navigation/use_csp_breadcrumbs'; import { findingsNavigation } from '../../../../common/navigation/constants'; -import { useResourceFindings } from './use_resource_findings'; +import { ResourceFindingsQuery, useResourceFindings } from './use_resource_findings'; import { useUrlQuery } from '../../../../common/hooks/use_url_query'; import type { FindingsBaseURLQuery } from '../../types'; -import { getBaseQuery } from '../../utils'; +import { getBaseQuery, getPaginationQuery, getPaginationTableParams } from '../../utils'; import { ResourceFindingsTable } from './resource_findings_table'; import { FindingsSearchBar } from '../../layout/findings_search_bar'; -const getDefaultQuery = (): FindingsBaseURLQuery => ({ +const getDefaultQuery = (): FindingsBaseURLQuery & ResourceFindingsQuery => ({ query: { language: 'kuery', query: '' }, filters: [], + pageIndex: 0, + pageSize: 10, }); -const BackToResourcesButton = () => { - return ( - - - - - - ); -}; +const BackToResourcesButton = () => ( + + + + + +); export const ResourceFindings = ({ dataView }: { dataView: DataView }) => { useCspBreadcrumbs([findingsNavigation.findings_default]); @@ -47,8 +47,16 @@ export const ResourceFindings = ({ dataView }: { dataView: DataView }) => { const { urlQuery, setUrlQuery } = useUrlQuery(getDefaultQuery); const resourceFindings = useResourceFindings({ - ...getBaseQuery({ dataView, filters: urlQuery.filters, query: urlQuery.query }), resourceId: params.resourceId, + ...getBaseQuery({ + dataView, + filters: urlQuery.filters, + query: urlQuery.query, + }), + ...getPaginationQuery({ + pageSize: urlQuery.pageSize, + pageIndex: urlQuery.pageIndex, + }), }); return ( @@ -58,7 +66,7 @@ export const ResourceFindings = ({ dataView }: { dataView: DataView }) => { setQuery={setUrlQuery} query={urlQuery.query} filters={urlQuery.filters} - loading={resourceFindings.isLoading} + loading={resourceFindings.isFetching} /> @@ -77,9 +85,17 @@ export const ResourceFindings = ({ dataView }: { dataView: DataView }) => { + setUrlQuery({ pageIndex: page.index, pageSize: page.size }) + } />
    diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/resource_findings_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/resource_findings_table.tsx index ec04d05109cdd6b..8300cdd503fee11 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/resource_findings_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/resource_findings_table.tsx @@ -5,17 +5,27 @@ * 2.0. */ import React from 'react'; -import { EuiEmptyPrompt, EuiBasicTable } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiBasicTable, CriteriaWithPagination, Pagination } from '@elastic/eui'; import { extractErrorMessage } from '../../../../../common/utils/helpers'; import * as TEXT from '../../translations'; import type { ResourceFindingsResult } from './use_resource_findings'; import { getFindingsColumns } from '../../layout/findings_layout'; +import type { CspFinding } from '../../types'; -type FindingsGroupByResourceProps = ResourceFindingsResult; +interface Props extends ResourceFindingsResult { + pagination: Pagination; + setTableOptions(options: CriteriaWithPagination): void; +} const columns = getFindingsColumns(); -const ResourceFindingsTableComponent = ({ error, data, loading }: FindingsGroupByResourceProps) => { +const ResourceFindingsTableComponent = ({ + error, + data, + loading, + pagination, + setTableOptions, +}: Props) => { if (!loading && !data?.page.length) return {TEXT.NO_FINDINGS}
    } />; @@ -25,6 +35,8 @@ const ResourceFindingsTableComponent = ({ error, data, loading }: FindingsGroupB error={error ? extractErrorMessage(error) : undefined} items={data?.page || []} columns={columns} + onChange={setTableOptions} + pagination={pagination} /> ); }; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/use_resource_findings.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/use_resource_findings.ts index 7123b80ef022891..9e015d84e20431d 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/use_resource_findings.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/use_resource_findings.ts @@ -8,12 +8,20 @@ import { useQuery } from 'react-query'; import { lastValueFrom } from 'rxjs'; import { IEsSearchResponse } from '@kbn/data-plugin/common'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { Pagination } from '@elastic/eui'; import { useKibana } from '../../../../common/hooks/use_kibana'; import { showErrorToast } from '../../latest_findings/use_latest_findings'; import type { CspFinding, FindingsBaseEsQuery, FindingsQueryResult } from '../../types'; interface UseResourceFindingsOptions extends FindingsBaseEsQuery { resourceId: string; + from: NonNullable; + size: NonNullable; +} + +export interface ResourceFindingsQuery { + pageIndex: Pagination['pageIndex']; + pageSize: Pagination['pageSize']; } export type ResourceFindingsResult = FindingsQueryResult< @@ -21,12 +29,16 @@ export type ResourceFindingsResult = FindingsQueryResult< unknown >; -export const getResourceFindingsQuery = ({ +const getResourceFindingsQuery = ({ index, query, resourceId, + from, + size, }: UseResourceFindingsOptions): estypes.SearchRequest => ({ index, + from, + size, body: { query: { ...query, @@ -38,21 +50,28 @@ export const getResourceFindingsQuery = ({ }, }); -export const useResourceFindings = ({ index, query, resourceId }: UseResourceFindingsOptions) => { +export const useResourceFindings = ({ + index, + query, + resourceId, + from, + size, +}: UseResourceFindingsOptions) => { const { data, notifications: { toasts }, } = useKibana().services; return useQuery( - ['csp_resource_findings', { index, query, resourceId }], + ['csp_resource_findings', { index, query, resourceId, from, size }], () => lastValueFrom>( data.search.search({ - params: getResourceFindingsQuery({ index, query, resourceId }), + params: getResourceFindingsQuery({ index, query, resourceId, from, size }), }) ), { + keepPreviousData: true, select: ({ rawResponse: { hits } }) => ({ page: hits.hits.map((hit) => hit._source!), total: hits.total as number, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_distribution_bar.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_distribution_bar.tsx index 0654f7d9f099945..c155b1cae7eda38 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_distribution_bar.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_distribution_bar.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useMemo } from 'react'; +import React from 'react'; import { css } from '@emotion/react'; import { EuiHealth, @@ -29,35 +29,25 @@ interface Props { const formatNumber = (value: number) => (value < 1000 ? value : numeral(value).format('0.0a')); -export const FindingsDistributionBar = ({ failed, passed, total, pageEnd, pageStart }: Props) => { - const count = useMemo( - () => - total - ? { total, passed: passed / total, failed: failed / total } - : { total: 0, passed: 0, failed: 0 }, - [total, failed, passed] - ); - - return ( -
    - - - -
    - ); -}; +export const FindingsDistributionBar = (props: Props) => ( +
    + + + +
    +); const Counters = ({ pageStart, pageEnd, total, failed, passed }: Props) => ( - {!!total && } + - {!!total && } + ); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_layout.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_layout.tsx index ee1a00abc4901dd..1374f98b459335b 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_layout.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_layout.tsx @@ -16,6 +16,7 @@ import { } from '@elastic/eui'; import { css } from '@emotion/react'; import moment from 'moment'; +import { i18n } from '@kbn/i18n'; import { CspEvaluationBadge } from '../../../components/csp_evaluation_badge'; import * as TEXT from '../translations'; import { CspFinding } from '../types'; @@ -52,8 +53,10 @@ export const getExpandColumn = ({ width: '40px', actions: [ { - name: 'Expand', - description: 'Expand', + name: i18n.translate('xpack.csp.expandColumnNameLabel', { defaultMessage: 'Expand' }), + description: i18n.translate('xpack.csp.expandColumnDescriptionLabel', { + defaultMessage: 'Expand', + }), type: 'icon', icon: 'expand', onClick, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/translations.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/translations.ts index 53d4b8a86e5c075..6301625f9bba1e9 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/translations.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/translations.ts @@ -42,10 +42,18 @@ export const RESOURCE = i18n.translate('xpack.csp.findings.resourceLabel', { defaultMessage: 'Resource', }); +export const RESOURCE_NAME = i18n.translate('xpack.csp.findings.resourceNameLabel', { + defaultMessage: 'Resource Name', +}); + export const GENERAL = i18n.translate('xpack.csp.findings.findingsFlyout.generalTabLabel', { defaultMessage: 'General', }); +export const OVERVIEW = i18n.translate('xpack.csp.findings.findingsFlyout.overviewTabLabel', { + defaultMessage: 'Overview', +}); + export const JSON = i18n.translate('xpack.csp.findings.findingsFlyout.jsonTabLabel', { defaultMessage: 'JSON', }); @@ -137,6 +145,10 @@ export const RULE_EVALUATED_AT = i18n.translate('xpack.csp.findings.ruleEvaluate defaultMessage: 'Rule evaluated at', }); +export const EVALUATED_AT = i18n.translate('xpack.csp.findings.evaluatedAt', { + defaultMessage: 'Evaluated at', +}); + export const FRAMEWORK_SOURCES = i18n.translate('xpack.csp.findings.frameworkSourcesLabel', { defaultMessage: 'Framework Sources', }); @@ -173,6 +185,10 @@ export const EXPECTED = i18n.translate('xpack.csp.findings.expectedLabel', { defaultMessage: 'Expected', }); +export const ACTUAL = i18n.translate('xpack.csp.findings.actualLabel', { + defaultMessage: 'Actual', +}); + export const EVIDENCE = i18n.translate('xpack.csp.findings.evidenceLabel', { defaultMessage: 'Evidence', }); @@ -237,7 +253,11 @@ export const NO_FINDINGS = i18n.translate('xpack.csp.findings.nonFindingsLabel', defaultMessage: 'There are no Findings', }); +export const DETAILS = i18n.translate('xpack.csp.findings.findingsFlyout.detailsTabLabel', { + defaultMessage: 'Details', +}); + export const FINDINGS_SEARCH_PLACEHOLDER = i18n.translate( 'xpack.csp.findings.searchBar.searchPlaceholder', - { defaultMessage: 'Search findings (eg. resource.section : "API Server")' } + { defaultMessage: 'Search findings (eg. rule.section.keyword : "API Server" )' } ); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/types.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/types.ts index 9fed484a8812843..57646c6883abb75 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/types.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/types.ts @@ -69,6 +69,7 @@ interface CspFindingResource { mode: string; path: string; type: string; + name: string; [other_keys: string]: unknown; } diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings_count.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings_count.ts index f48e630b489d4bf..a63a3fac32c8b3b 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings_count.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings_count.ts @@ -48,6 +48,7 @@ export const useFindingsCounter = ({ index, query }: FindingsBaseEsQuery) => { }) ), { + keepPreviousData: true, onError: (err) => showErrorToast(toasts, err), select: (response) => Object.fromEntries( diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/utils.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/utils.ts index d3281a1a0dbc8cf..5f4d5749303704a 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/utils.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/utils.ts @@ -7,6 +7,7 @@ import { buildEsQuery } from '@kbn/es-query'; import type { DataView } from '@kbn/data-plugin/common'; +import { EuiBasicTableProps, Pagination } from '@elastic/eui'; import type { FindingsBaseEsQuery, FindingsBaseURLQuery } from './types'; export const getBaseQuery = ({ @@ -20,3 +21,23 @@ export const getBaseQuery = ({ // will be accounted for before releasing the feature query: buildEsQuery(dataView, query, filters), }); + +type TablePagination = NonNullable['pagination']>; + +export const getPaginationTableParams = ( + params: TablePagination & Pick, 'pageIndex' | 'pageSize'>, + pageSizeOptions = [10, 25, 100], + showPerPageOptions = true +): Required => ({ + ...params, + pageSizeOptions, + showPerPageOptions, +}); + +export const getPaginationQuery = ({ + pageIndex, + pageSize, +}: Pick) => ({ + from: pageIndex * pageSize, + size: pageSize, +}); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.test.ts index 27dcd3cee670356..d0326fb037b609a 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.test.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.test.ts @@ -13,7 +13,6 @@ import { httpServerMock, } from '@kbn/core/server/mocks'; import { - convertRulesConfigToYaml, createRulesConfig, defineUpdateRulesConfigRoute, getCspRules, @@ -144,7 +143,9 @@ describe('Update rules configuration API', () => { ], } as unknown as SavedObjectsFindResponse; const cspConfig = await createRulesConfig(cspRules); - expect(cspConfig).toMatchObject({ activated_rules: { cis_k8s: ['cis_1_1_1', 'cis_1_1_3'] } }); + expect(cspConfig).toMatchObject({ + data_yaml: { activated_rules: { cis_k8s: ['cis_1_1_1', 'cis_1_1_3'] } }, + }); }); it('create empty csp rules config when all rules are disabled', async () => { @@ -169,21 +170,13 @@ describe('Update rules configuration API', () => { ], } as unknown as SavedObjectsFindResponse; const cspConfig = await createRulesConfig(cspRules); - expect(cspConfig).toMatchObject({ activated_rules: { cis_k8s: [] } }); - }); - - it('validate converting rules config object to Yaml', async () => { - const cspRuleConfig = { activated_rules: { cis_k8s: ['1.1.1', '1.1.2'] } }; - - const dataYaml = convertRulesConfigToYaml(cspRuleConfig); - - expect(dataYaml).toEqual('activated_rules:\n cis_k8s:\n - 1.1.1\n - 1.1.2\n'); + expect(cspConfig).toMatchObject({ data_yaml: { activated_rules: { cis_k8s: [] } } }); }); it('validate adding new data.yaml to package policy instance', async () => { const packagePolicy = createPackagePolicyMock(); - const dataYaml = 'activated_rules:\n cis_k8s:\n - 1.1.1\n - 1.1.2\n'; + const dataYaml = 'data_yaml:\n activated_rules:\n cis_k8s:\n - 1.1.1\n - 1.1.2\n'; const updatedPackagePolicy = setVarToPackagePolicy(packagePolicy, dataYaml); expect(updatedPackagePolicy.vars).toEqual({ dataYaml: { type: 'config', value: dataYaml } }); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.ts b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.ts index 21587394d51e872..72c19fd5e37dddf 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.ts @@ -66,8 +66,10 @@ export const createRulesConfig = ( ): CspRulesConfigSchema => { const activatedRules = cspRules.saved_objects.filter((cspRule) => cspRule.attributes.enabled); const config = { - activated_rules: { - cis_k8s: activatedRules.map((activatedRule) => activatedRule.attributes.rego_rule_id), + data_yaml: { + activated_rules: { + cis_k8s: activatedRules.map((activatedRule) => activatedRule.attributes.rego_rule_id), + }, }, }; return config; diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js index 3e48c82f21fbe72..16d38d42d46b2de 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js @@ -5,13 +5,6 @@ * 2.0. */ -/** - * The below import is required to avoid a console error warn from brace package - * console.warn ../node_modules/brace/index.js:3999 - Could not load worker ReferenceError: Worker is not defined - at createWorker (//node_modules/brace/index.js:17992:5) - */ -import { stubWebWorker } from '@kbn/test-jest-helpers'; // eslint-disable-line no-unused-vars import { act } from 'react-dom/test-utils'; import { getFollowerIndexMock } from './fixtures/follower_index'; diff --git a/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_provider.js b/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_provider.js index f9ad9ba2a8d7d09..c8993441f2672eb 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_provider.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/components/remote_clusters_provider.js @@ -5,7 +5,7 @@ * 2.0. */ -import React, { PureComponent } from 'react'; // eslint-disable-line no-unused-vars +import { PureComponent } from 'react'; // eslint-disable-line no-unused-vars import { loadRemoteClusters } from '../services/api'; export class RemoteClustersProvider extends PureComponent { diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/file_data_visualizer_view.js b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/file_data_visualizer_view.js index 9495c599e73d4d0..c34a6e0b9cbd9a3 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/file_data_visualizer_view.js +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/file_data_visualizer_view/file_data_visualizer_view.js @@ -65,7 +65,6 @@ export class FileDataVisualizerView extends Component { linesToSample: DEFAULT_LINES_TO_SAMPLE, }; - this.savedObjectsClient = props.savedObjectsClient; this.maxFileUploadBytes = props.fileUpload.getMaxBytes(); } @@ -367,7 +366,6 @@ export class FileDataVisualizerView extends Component { dataViewsContract={this.props.dataViewsContract} showBottomBar={this.showBottomBar} hideBottomBar={this.hideBottomBar} - savedObjectsClient={this.savedObjectsClient} fileUpload={this.props.fileUpload} getAdditionalLinks={this.props.getAdditionalLinks} capabilities={this.props.capabilities} diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import_view.js b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import_view.js index 006bd9e3356f7cd..5aaeafe563e1d44 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import_view.js +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/components/import_view/import_view.js @@ -77,7 +77,7 @@ export class ImportView extends Component { super(props); this.state = getDefaultState(DEFAULT_STATE, this.props.results, this.props.capabilities); - this.savedObjectsClient = props.savedObjectsClient; + this.dataViewsContract = props.dataViewsContract; } componentDidMount() { @@ -417,14 +417,7 @@ export class ImportView extends Component { async loadDataViewNames() { try { - const dataViewNames = ( - await this.savedObjectsClient.find({ - type: 'index-pattern', - fields: ['title'], - perPage: 10000, - }) - ).savedObjects.map(({ attributes }) => attributes && attributes.title); - + const dataViewNames = await this.dataViewsContract.getTitles(); this.setState({ dataViewNames }); } catch (error) { console.error('failed to load data views', error); diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/file_data_visualizer.tsx b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/file_data_visualizer.tsx index 0238659b1f348a1..edd5ad4bbe1b269 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/file_data_visualizer.tsx +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/file_data_visualizer.tsx @@ -42,7 +42,6 @@ export const FileDataVisualizer: FC = ({ getAdditionalLinks }) => { = ({ return ( createEncryptedSavedObjectsClienttMock(opts)), + getClient: jest.fn((opts) => createEncryptedSavedObjectsClientMock(opts)), } as jest.Mocked; } -function createEncryptedSavedObjectsClienttMock(opts?: EncryptedSavedObjectsClientOptions) { +function createEncryptedSavedObjectsClientMock(opts?: EncryptedSavedObjectsClientOptions) { return { getDecryptedAsInternalUser: jest.fn(), + createPointInTimeFinderDecryptedAsInternalUser: jest.fn(), } as jest.Mocked; } export const encryptedSavedObjectsMock = { createSetup: createEncryptedSavedObjectsSetupMock, createStart: createEncryptedSavedObjectsStartMock, - createClient: createEncryptedSavedObjectsClienttMock, + createClient: createEncryptedSavedObjectsClientMock, }; diff --git a/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts b/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts index f4708e182ad3123..970f3baed7ab1a9 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts @@ -60,10 +60,11 @@ describe('EncryptedSavedObjects Plugin', () => { `); expect(startContract.getClient()).toMatchInlineSnapshot(` - Object { - "getDecryptedAsInternalUser": [Function], - } - `); + Object { + "createPointInTimeFinderDecryptedAsInternalUser": [Function], + "getDecryptedAsInternalUser": [Function], + } + `); }); }); }); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.test.ts index 032842e0047c0f5..b93141a3ad9898d 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.test.ts @@ -166,5 +166,171 @@ describe('#setupSavedObjects', () => { { namespace: 'some-ns' } ); }); + + it('does not call decryptAttributes if Saved Object type is not registered', async () => { + const mockSavedObject: SavedObject = { + id: 'some-id', + type: 'not-known-type', + attributes: { attrOne: 'one', attrSecret: '*secret*' }, + references: [], + }; + mockSavedObjectsRepository.get.mockResolvedValue(mockSavedObject); + + await expect( + setupContract().getDecryptedAsInternalUser(mockSavedObject.type, mockSavedObject.id, { + namespace: 'some-ns', + }) + ).resolves.toEqual(mockSavedObject); + + expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledTimes(0); + }); + }); + + describe('#createPointInTimeFinderDecryptedAsInternalUser', () => { + it('includes `namespace` for single-namespace saved objects', async () => { + const mockSavedObject: SavedObject = { + id: 'some-id', + type: 'known-type', + attributes: { attrOne: 'one', attrSecret: '*secret*' }, + references: [], + }; + mockSavedObjectsRepository.createPointInTimeFinder = jest.fn().mockReturnValue({ + close: jest.fn(), + find: function* asyncGenerator() { + yield { saved_objects: [mockSavedObject] }; + }, + }); + + mockSavedObjectTypeRegistry.isSingleNamespace.mockReturnValue(true); + + const finder = await setupContract().createPointInTimeFinderDecryptedAsInternalUser({ + type: 'known-type', + namespaces: ['some-ns'], + }); + + for await (const res of finder.find()) { + expect(res).toEqual({ + saved_objects: [ + { + ...mockSavedObject, + attributes: { attrOne: 'one', attrSecret: 'secret' }, + }, + ], + }); + } + + expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledTimes(1); + expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledWith( + { type: mockSavedObject.type, id: mockSavedObject.id, namespace: 'some-ns' }, + mockSavedObject.attributes + ); + + expect(mockSavedObjectsRepository.createPointInTimeFinder).toHaveBeenCalledTimes(1); + expect(mockSavedObjectsRepository.createPointInTimeFinder).toHaveBeenCalledWith( + { type: 'known-type', namespaces: ['some-ns'] }, + undefined + ); + }); + + it('does not include `namespace` for multiple-namespace saved objects', async () => { + const mockSavedObject: SavedObject = { + id: 'some-id', + type: 'known-type', + attributes: { attrOne: 'one', attrSecret: '*secret*' }, + references: [], + }; + mockSavedObjectsRepository.createPointInTimeFinder = jest.fn().mockReturnValue({ + close: jest.fn(), + find: function* asyncGenerator() { + yield { saved_objects: [mockSavedObject] }; + }, + }); + + mockSavedObjectTypeRegistry.isSingleNamespace.mockReturnValue(false); + + const finder = await setupContract().createPointInTimeFinderDecryptedAsInternalUser({ + type: 'known-type', + namespaces: ['some-ns'], + }); + + for await (const res of finder.find()) { + expect(res).toEqual({ + saved_objects: [ + { + ...mockSavedObject, + attributes: { attrOne: 'one', attrSecret: 'secret' }, + }, + ], + }); + } + + expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledTimes(1); + expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledWith( + { type: mockSavedObject.type, id: mockSavedObject.id, namespace: undefined }, + mockSavedObject.attributes + ); + + expect(mockSavedObjectsRepository.createPointInTimeFinder).toHaveBeenCalledTimes(1); + expect(mockSavedObjectsRepository.createPointInTimeFinder).toHaveBeenCalledWith( + { type: 'known-type', namespaces: ['some-ns'] }, + undefined + ); + }); + + it('does not call decryptAttributes if Saved Object type is not registered', async () => { + const mockSavedObject: SavedObject = { + id: 'some-id', + type: 'not-known-type', + attributes: { attrOne: 'one', attrSecret: '*secret*' }, + references: [], + }; + mockSavedObjectsRepository.createPointInTimeFinder = jest.fn().mockReturnValue({ + close: jest.fn(), + find: function* asyncGenerator() { + yield { saved_objects: [mockSavedObject] }; + }, + }); + + const finder = await setupContract().createPointInTimeFinderDecryptedAsInternalUser({ + type: 'not-known-type', + namespaces: ['some-ns'], + }); + + for await (const res of finder.find()) { + expect(res).toEqual({ + saved_objects: [mockSavedObject], + }); + } + + expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledTimes(0); + }); + + it('returns error within Saved Object if decryption failed', async () => { + const mockSavedObject: SavedObject = { + id: 'some-id', + type: 'known-type', + attributes: { attrOne: 'one', attrSecret: '*secret*' }, + references: [], + }; + mockSavedObjectsRepository.createPointInTimeFinder = jest.fn().mockReturnValue({ + close: jest.fn(), + find: function* asyncGenerator() { + yield { saved_objects: [mockSavedObject] }; + }, + }); + + mockEncryptedSavedObjectsService.decryptAttributes.mockImplementation(() => { + throw new Error('Test failure'); + }); + + const finder = await setupContract().createPointInTimeFinderDecryptedAsInternalUser({ + type: 'known-type', + namespaces: ['some-ns'], + }); + + for await (const res of finder.find()) { + expect(res.saved_objects[0].error).toHaveProperty('message', 'Test failure'); + } + }); }); }); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts index 3d9d36206b5c92d..e2b58e3003d969b 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts @@ -5,11 +5,16 @@ * 2.0. */ +import pMap from 'p-map'; + import type { + ISavedObjectsPointInTimeFinder, ISavedObjectsRepository, ISavedObjectTypeRegistry, SavedObject, SavedObjectsBaseOptions, + SavedObjectsCreatePointInTimeFinderDependencies, + SavedObjectsCreatePointInTimeFinderOptions, SavedObjectsServiceSetup, StartServicesAccessor, } from '@kbn/core/server'; @@ -43,6 +48,31 @@ export interface EncryptedSavedObjectsClient { id: string, options?: SavedObjectsBaseOptions ) => Promise>; + + /** + * API method, that can be used to help page through large sets of saved objects and returns decrypted properties in result SO. + * Its interface matches interface of the corresponding Saved Objects API `createPointInTimeFinder` method: + * + * @example + * ```ts + * const finder = await this.encryptedSavedObjectsClient.createPointInTimeFinderDecryptedAsInternalUser({ + * filter, + * type: 'my-saved-object-type', + * perPage: 1000, + * }); + * for await (const response of finder.find()) { + * // process response + * } + * ``` + * + * @param findOptions matches interface of corresponding argument of Saved Objects API `createPointInTimeFinder` {@link SavedObjectsCreatePointInTimeFinderOptions} + * @param dependencies matches interface of corresponding argument of Saved Objects API `createPointInTimeFinder` {@link SavedObjectsCreatePointInTimeFinderDependencies} + * + */ + createPointInTimeFinderDecryptedAsInternalUser( + findOptions: SavedObjectsCreatePointInTimeFinderOptions, + dependencies?: SavedObjectsCreatePointInTimeFinderDependencies + ): Promise>; } export function setupSavedObjects({ @@ -84,6 +114,11 @@ export function setupSavedObjects({ ): Promise> => { const [internalRepository, typeRegistry] = await internalRepositoryAndTypeRegistryPromise; const savedObject = await internalRepository.get(type, id, options); + + if (!service.isRegistered(savedObject.type)) { + return savedObject as SavedObject; + } + return { ...savedObject, attributes: (await service.decryptAttributes( @@ -96,6 +131,61 @@ export function setupSavedObjects({ )) as T, }; }, + + createPointInTimeFinderDecryptedAsInternalUser: async ( + findOptions: SavedObjectsCreatePointInTimeFinderOptions, + dependencies?: SavedObjectsCreatePointInTimeFinderDependencies + ): Promise> => { + const [internalRepository, typeRegistry] = await internalRepositoryAndTypeRegistryPromise; + const finder = internalRepository.createPointInTimeFinder(findOptions, dependencies); + const finderAsyncGenerator = finder.find(); + + async function* encryptedFinder() { + for await (const res of finderAsyncGenerator) { + const encryptedSavedObjects = await pMap( + res.saved_objects, + async (savedObject) => { + if (!service.isRegistered(savedObject.type)) { + return savedObject; + } + + const descriptor = { + type: savedObject.type, + id: savedObject.id, + namespace: getDescriptorNamespace( + typeRegistry, + savedObject.type, + findOptions.namespaces + ), + }; + + try { + return { + ...savedObject, + attributes: (await service.decryptAttributes( + descriptor, + savedObject.attributes as Record + )) as T, + }; + } catch (error) { + // catch error and enrich SO with it, return stripped attributes. Then consumer of API can decide either proceed + // with only unsecured properties or stop when error happens + const { attributes: strippedAttrs } = await service.stripOrDecryptAttributes( + descriptor, + savedObject.attributes as Record + ); + return { ...savedObject, attributes: strippedAttrs as T, error }; + } + }, + { concurrency: 50 } + ); + + yield { ...res, saved_objects: encryptedSavedObjects }; + } + } + + return { ...finder, find: () => encryptedFinder() }; + }, }; }; } diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx index 13a13c25a5ad85e..79460ac486f1f08 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx @@ -13,6 +13,9 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer, EuiText, EuiTitle } from import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { docLinks } from '../../../shared/doc_links'; import { WEB_CRAWLER_DOCS_URL, WEB_CRAWLER_LOG_DOCS_URL } from '../../routes'; import { getEngineBreadcrumbs } from '../engine'; import { AppSearchPageTemplate } from '../layout'; @@ -43,6 +46,34 @@ export const CrawlerOverview: React.FC = () => { pageHeader={{ pageTitle: CRAWLER_TITLE, rightSideItems: [, ], + description: ( + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.ingestionPluginDocumentationLink', + { defaultMessage: 'Elasticsearch ingest attachment plugin' } + )} + + ), + deploymentSettingsDocumentationLink: ( + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.deploymentSettingsDocumentationLink', + { defaultMessage: 'review your deployment settings' } + )} + + ), + }} + /> + ), }} isLoading={dataLoading} > diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.test.tsx index 3bc7eac1a9e692e..a5d0e8facf236f5 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.test.tsx @@ -44,7 +44,7 @@ describe('DocumentCreationFlyout', () => { const wrapper = shallow(); expect(wrapper.find(EuiFlyout)).toHaveLength(1); - wrapper.find(EuiFlyout).prop('onClose')(); + wrapper.find(EuiFlyout).prop('onClose')(new MouseEvent('click')); expect(actions.closeDocumentCreation).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts index b507e5466f13f89..b037a5aed621712 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/doc_links/doc_links.ts @@ -30,6 +30,7 @@ class DocLinks { public appSearchSynonyms: string; public appSearchWebCrawler: string; public appSearchWebCrawlerEventLogs: string; + public appSearchWebCrawlerReference: string; public clientsGoIndex: string; public clientsGuide: string; public clientsJavaBasicAuthentication: string; @@ -58,6 +59,7 @@ class DocLinks { public enterpriseSearchUsersAccess: string; public kibanaSecurity: string; public licenseManagement: string; + public pluginsIngestAttachment: string; public queryDsl: string; public workplaceSearchApiKeys: string; public workplaceSearchBox: string; @@ -110,6 +112,7 @@ class DocLinks { this.appSearchSynonyms = ''; this.appSearchWebCrawler = ''; this.appSearchWebCrawlerEventLogs = ''; + this.appSearchWebCrawlerReference = ''; this.clientsGoIndex = ''; this.clientsGuide = ''; this.clientsJavaBasicAuthentication = ''; @@ -138,6 +141,7 @@ class DocLinks { this.enterpriseSearchUsersAccess = ''; this.kibanaSecurity = ''; this.licenseManagement = ''; + this.pluginsIngestAttachment = ''; this.queryDsl = ''; this.workplaceSearchApiKeys = ''; this.workplaceSearchBox = ''; @@ -192,6 +196,7 @@ class DocLinks { this.appSearchSynonyms = docLinks.links.appSearch.synonyms; this.appSearchWebCrawler = docLinks.links.appSearch.webCrawler; this.appSearchWebCrawlerEventLogs = docLinks.links.appSearch.webCrawlerEventLogs; + this.appSearchWebCrawlerReference = docLinks.links.appSearch.webCrawlerReference; this.clientsGoIndex = docLinks.links.clients.goIndex; this.clientsGuide = docLinks.links.clients.guide; this.clientsJavaBasicAuthentication = docLinks.links.clients.javaBasicAuthentication; @@ -220,6 +225,7 @@ class DocLinks { this.enterpriseSearchUsersAccess = docLinks.links.enterpriseSearch.usersAccess; this.kibanaSecurity = docLinks.links.kibana.xpackSecurity; this.licenseManagement = docLinks.links.enterpriseSearch.licenseManagement; + this.pluginsIngestAttachment = docLinks.links.plugins.ingestAttachment; this.queryDsl = docLinks.links.query.queryDsl; this.workplaceSearchApiKeys = docLinks.links.workplaceSearch.apiKeys; this.workplaceSearchBox = docLinks.links.workplaceSearch.box; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx index b69303aae210691..0213aa26d5ef3b4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.test.tsx @@ -21,6 +21,8 @@ import { SOURCES_PATH, PRIVATE_SOURCES_PATH, SOURCE_DETAILS_PATH, + getAddPath, + getEditPath, } from './routes'; const TestComponent = ({ id, isOrg }: { id: string; isOrg?: boolean }) => { @@ -86,3 +88,32 @@ describe('getReindexJobRoute', () => { ); }); }); + +describe('getAddPath', () => { + it('should handle a service type', () => { + expect(getAddPath('share_point')).toEqual('/sources/add/share_point'); + }); + + it('should should handle an external service type with no base service type', () => { + expect(getAddPath('external')).toEqual('/sources/add/external'); + }); + + it('should should handle an external service type with a base service type', () => { + expect(getAddPath('external', 'share_point')).toEqual('/sources/add/share_point/external'); + }); + it('should should handle a custom service type with no base service type', () => { + expect(getAddPath('external')).toEqual('/sources/add/external'); + }); + + it('should should handle a custom service type with a base service type', () => { + expect(getAddPath('custom', 'share_point_server')).toEqual( + '/sources/add/share_point_server/custom' + ); + }); +}); + +describe('getEditPath', () => { + it('should handle a service type', () => { + expect(getEditPath('share_point')).toEqual('/settings/connectors/share_point/edit'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts index cbcd1d885b120ea..fe1be10aa3b0628 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -77,6 +77,14 @@ export const getReindexJobRoute = ( isOrganization: boolean ) => getSourcesPath(generatePath(REINDEX_JOB_PATH, { sourceId, activeReindexJobId }), isOrganization); -export const getAddPath = (serviceType: string): string => `${SOURCES_PATH}/add/${serviceType}`; + +export const getAddPath = (serviceType: string, baseServiceType?: string): string => { + const baseServiceTypePath = baseServiceType + ? `${baseServiceType}/${serviceType}` + : `${serviceType}`; + return `${SOURCES_PATH}/add/${baseServiceTypePath}`; +}; + +// TODO this should handle base service type once we are getting it back from registered external connectors export const getEditPath = (serviceType: string): string => `${ORG_SETTINGS_CONNECTORS_PATH}/${serviceType}/edit`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts index 984e6664681b47d..32353230b36aaaf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts @@ -72,18 +72,14 @@ export interface Configuration { export interface SourceDataItem { name: string; - iconName: string; categories?: string[]; serviceType: string; + baseServiceType?: string; configuration: Configuration; - configured?: boolean; connected?: boolean; features?: Features; objTypes?: string[]; accountContextOnly: boolean; - internalConnectorAvailable?: boolean; - externalConnectorAvailable?: boolean; - customConnectorAvailable?: boolean; isBeta?: boolean; } diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/has_multiple_connector_options.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/has_multiple_connector_options.ts deleted file mode 100644 index fbfda1ddf8d5ef1..000000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/has_multiple_connector_options.ts +++ /dev/null @@ -1,17 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { SourceDataItem } from '../types'; - -export const hasMultipleConnectorOptions = ({ - internalConnectorAvailable, - externalConnectorAvailable, - customConnectorAvailable, -}: SourceDataItem) => - [externalConnectorAvailable, internalConnectorAvailable, customConnectorAvailable].filter( - (available) => !!available - ).length > 1; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts index c66a6d1ca0fc0d0..6f6af758c028320 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/utils/index.ts @@ -11,6 +11,5 @@ export { mimeType } from './mime_types'; export { readUploadedFileAsBase64 } from './read_uploaded_file_as_base64'; export { readUploadedFileAsText } from './read_uploaded_file_as_text'; export { handlePrivateKeyUpload } from './handle_private_key_upload'; -export { hasMultipleConnectorOptions } from './has_multiple_connector_options'; export { isNotNullish } from './is_not_nullish'; export { sortByName } from './sort_by_name'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.test.tsx index b606f9d7f56fda9..9ff64dfe4f65b95 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.test.tsx @@ -7,6 +7,7 @@ import '../../../../../../__mocks__/shallow_useeffect.mock'; import { setMockValues } from '../../../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../../../__mocks__/react_router'; import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -17,7 +18,6 @@ import { WorkplaceSearchPageTemplate, PersonalDashboardLayout, } from '../../../../../components/layout'; -import { staticSourceData } from '../../../source_data'; import { AddCustomSource } from './add_custom_source'; import { AddCustomSourceSteps } from './add_custom_source_logic'; @@ -25,11 +25,6 @@ import { ConfigureCustom } from './configure_custom'; import { SaveCustom } from './save_custom'; describe('AddCustomSource', () => { - const props = { - sourceData: staticSourceData[0], - initialValues: undefined, - }; - const values = { sourceConfigData, isOrganization: true, @@ -37,17 +32,26 @@ describe('AddCustomSource', () => { beforeEach(() => { setMockValues({ ...values }); + mockUseParams.mockReturnValue({ baseServiceType: 'share_point_server' }); }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(1); }); + it('returns null if there is no matching source data for the service type', () => { + mockUseParams.mockReturnValue({ baseServiceType: 'doesnt_exist' }); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + it('should show correct layout for personal dashboard', () => { setMockValues({ isOrganization: false }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(0); expect(wrapper.find(PersonalDashboardLayout)).toHaveLength(1); @@ -55,14 +59,14 @@ describe('AddCustomSource', () => { it('should show Configure Custom for custom configuration step', () => { setMockValues({ currentStep: AddCustomSourceSteps.ConfigureCustomStep }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(ConfigureCustom)).toHaveLength(1); }); it('should show Save Custom for save custom step', () => { setMockValues({ currentStep: AddCustomSourceSteps.SaveCustomStep }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(SaveCustom)).toHaveLength(1); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.tsx index c2f6afba032c73d..b15129665a7d41f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source.tsx @@ -7,6 +7,8 @@ import React from 'react'; +import { useParams } from 'react-router-dom'; + import { useValues } from 'kea'; import { AppLogic } from '../../../../../app_logic'; @@ -16,27 +18,38 @@ import { } from '../../../../../components/layout'; import { NAV } from '../../../../../constants'; -import { SourceDataItem } from '../../../../../types'; +import { getSourceData } from '../../../source_data'; import { AddCustomSourceLogic, AddCustomSourceSteps } from './add_custom_source_logic'; import { ConfigureCustom } from './configure_custom'; import { SaveCustom } from './save_custom'; -interface Props { - sourceData: SourceDataItem; - initialValue?: string; -} -export const AddCustomSource: React.FC = ({ sourceData, initialValue = '' }) => { - const addCustomSourceLogic = AddCustomSourceLogic({ sourceData, initialValue }); +export const AddCustomSource: React.FC = () => { + const { baseServiceType } = useParams<{ baseServiceType?: string }>(); + const sourceData = getSourceData('custom', baseServiceType); + + const addCustomSourceLogic = AddCustomSourceLogic({ + baseServiceType, + initialValue: sourceData?.name, + }); + const { currentStep } = useValues(addCustomSourceLogic); const { isOrganization } = useValues(AppLogic); + if (!sourceData) { + return null; + } + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; return ( - - {currentStep === AddCustomSourceSteps.ConfigureCustomStep && } - {currentStep === AddCustomSourceSteps.SaveCustomStep && } + + {currentStep === AddCustomSourceSteps.ConfigureCustomStep && ( + + )} + {currentStep === AddCustomSourceSteps.SaveCustomStep && ( + + )} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.test.ts index d2187bd0b21a158..2ca3462da0f5793 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.test.ts @@ -14,7 +14,6 @@ import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock' import { nextTick } from '@kbn/test-jest-helpers'; -import { docLinks } from '../../../../../../shared/doc_links'; import { itShowsServerErrorAsFlashMessage } from '../../../../../../test_helpers'; jest.mock('../../../../../app_logic', () => ({ @@ -22,35 +21,17 @@ jest.mock('../../../../../app_logic', () => ({ })); import { AppLogic } from '../../../../../app_logic'; -import { SOURCE_NAMES } from '../../../../../constants'; -import { CustomSource, SourceDataItem } from '../../../../../types'; +import { CustomSource } from '../../../../../types'; import { AddCustomSourceLogic, AddCustomSourceSteps } from './add_custom_source_logic'; -const CUSTOM_SOURCE_DATA_ITEM: SourceDataItem = { - name: SOURCE_NAMES.CUSTOM, - iconName: SOURCE_NAMES.CUSTOM, - serviceType: 'custom', - configuration: { - isPublicKey: false, - hasOauthRedirect: false, - needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchCustomSources, - applicationPortalUrl: '', - }, - accountContextOnly: false, -}; - const DEFAULT_VALUES = { currentStep: AddCustomSourceSteps.ConfigureCustomStep, buttonLoading: false, customSourceNameValue: '', newCustomSource: {} as CustomSource, - sourceData: CUSTOM_SOURCE_DATA_ITEM, }; -const MOCK_PROPS = { initialValue: '', sourceData: CUSTOM_SOURCE_DATA_ITEM }; - const MOCK_NAME = 'name'; describe('AddCustomSourceLogic', () => { @@ -60,7 +41,7 @@ describe('AddCustomSourceLogic', () => { beforeEach(() => { jest.clearAllMocks(); - mount({}, MOCK_PROPS); + mount({}); }); it('has expected default values', () => { @@ -112,12 +93,9 @@ describe('AddCustomSourceLogic', () => { describe('listeners', () => { beforeEach(() => { - mount( - { - customSourceNameValue: MOCK_NAME, - }, - MOCK_PROPS - ); + mount({ + customSourceNameValue: MOCK_NAME, + }); }); describe('organization context', () => { @@ -151,11 +129,7 @@ describe('AddCustomSourceLogic', () => { customSourceNameValue: MOCK_NAME, }, { - ...MOCK_PROPS, - sourceData: { - ...CUSTOM_SOURCE_DATA_ITEM, - serviceType: 'sharepoint-server', - }, + baseServiceType: 'share_point_server', } ); @@ -165,7 +139,7 @@ describe('AddCustomSourceLogic', () => { body: JSON.stringify({ service_type: 'custom', name: MOCK_NAME, - base_service_type: 'sharepoint-server', + base_service_type: 'share_point_server', }), }); }); @@ -199,11 +173,7 @@ describe('AddCustomSourceLogic', () => { customSourceNameValue: MOCK_NAME, }, { - ...MOCK_PROPS, - sourceData: { - ...CUSTOM_SOURCE_DATA_ITEM, - serviceType: 'sharepoint-server', - }, + baseServiceType: 'share_point_server', } ); @@ -215,7 +185,7 @@ describe('AddCustomSourceLogic', () => { body: JSON.stringify({ service_type: 'custom', name: MOCK_NAME, - base_service_type: 'sharepoint-server', + base_service_type: 'share_point_server', }), } ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.ts index f85e0761f51b5be..5b02fffa5892d66 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/add_custom_source_logic.ts @@ -10,11 +10,11 @@ import { kea, MakeLogicType } from 'kea'; import { flashAPIErrors, clearFlashMessages } from '../../../../../../shared/flash_messages'; import { HttpLogic } from '../../../../../../shared/http'; import { AppLogic } from '../../../../../app_logic'; -import { CustomSource, SourceDataItem } from '../../../../../types'; +import { CustomSource } from '../../../../../types'; export interface AddCustomSourceProps { - sourceData: SourceDataItem; - initialValue: string; + baseServiceType?: string; + initialValue?: string; } export enum AddCustomSourceSteps { @@ -34,7 +34,6 @@ interface AddCustomSourceValues { currentStep: AddCustomSourceSteps; customSourceNameValue: string; newCustomSource: CustomSource; - sourceData: SourceDataItem; } /** @@ -67,7 +66,7 @@ export const AddCustomSourceLogic = kea< }, ], customSourceNameValue: [ - props.initialValue, + props.initialValue || '', { setCustomSourceNameValue: (_, customSourceNameValue) => customSourceNameValue, }, @@ -78,7 +77,6 @@ export const AddCustomSourceLogic = kea< setNewCustomSource: (_, newCustomSource) => newCustomSource, }, ], - sourceData: [props.sourceData], }), listeners: ({ actions, values, props }) => ({ createContentSource: async () => { @@ -90,21 +88,12 @@ export const AddCustomSourceLogic = kea< const { customSourceNameValue } = values; - const baseParams = { + const params = { service_type: 'custom', name: customSourceNameValue, + base_service_type: props.baseServiceType, }; - // pre-configured custom sources have a serviceType reflecting their target service - // we submit this as `base_service_type` to keep track of - const params = - props.sourceData.serviceType === 'custom' - ? baseParams - : { - ...baseParams, - base_service_type: props.sourceData.serviceType, - }; - try { const response = await HttpLogic.values.http.post(route, { body: JSON.stringify(params), diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.test.tsx index 3ed60614d294a17..a0713ec530b28bc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.test.tsx @@ -21,24 +21,24 @@ import { ConfigureCustom } from './configure_custom'; describe('ConfigureCustom', () => { const setCustomSourceNameValue = jest.fn(); const createContentSource = jest.fn(); + const sourceData = staticSourceData[1]; beforeEach(() => { setMockActions({ setCustomSourceNameValue, createContentSource }); setMockValues({ customSourceNameValue: 'name', buttonLoading: false, - sourceData: staticSourceData[1], }); }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiForm)).toHaveLength(1); }); it('handles input change', () => { - const wrapper = shallow(); + const wrapper = shallow(); const text = 'changed for the better'; const input = wrapper.find(EuiFieldText); input.simulate('change', { target: { value: text } }); @@ -47,7 +47,7 @@ describe('ConfigureCustom', () => { }); it('handles form submission', () => { - const wrapper = shallow(); + const wrapper = shallow(); const preventDefault = jest.fn(); wrapper.find('EuiForm').simulate('submit', { preventDefault }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.tsx index 024dd698cc0a25a..4f673f56231cccb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/configure_custom.tsx @@ -21,11 +21,13 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; + import { FormattedMessage } from '@kbn/i18n-react'; import { docLinks } from '../../../../../../shared/doc_links'; import connectionIllustration from '../../../../../assets/connection_illustration.svg'; +import { SourceDataItem } from '../../../../../types'; import { SOURCE_NAME_LABEL } from '../../../constants'; import { AddSourceHeader } from '../add_source_header'; @@ -33,9 +35,13 @@ import { CONFIG_CUSTOM_BUTTON, CONFIG_CUSTOM_LINK_TEXT, CONFIG_INTRO_ALT_TEXT } import { AddCustomSourceLogic } from './add_custom_source_logic'; -export const ConfigureCustom: React.FC = () => { +interface ConfigureCustomProps { + sourceData: SourceDataItem; +} + +export const ConfigureCustom: React.FC = ({ sourceData }) => { const { setCustomSourceNameValue, createContentSource } = useActions(AddCustomSourceLogic); - const { customSourceNameValue, buttonLoading, sourceData } = useValues(AddCustomSourceLogic); + const { customSourceNameValue, buttonLoading } = useValues(AddCustomSourceLogic); const handleFormSubmit = (e: FormEvent) => { e.preventDefault(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.test.tsx index 3de514a3e4d71db..8f4e6e7205ef2df 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.test.tsx @@ -25,18 +25,21 @@ const mockValues = { accessToken: 'token', name: 'name', }, - sourceData: staticCustomSourceData, }; +const sourceData = staticCustomSourceData; + describe('SaveCustom', () => { + beforeAll(() => { + jest.clearAllMocks(); + setMockValues(mockValues); + }); + describe('default behavior', () => { let wrapper: ShallowWrapper; beforeAll(() => { - jest.clearAllMocks(); - setMockValues(mockValues); - - wrapper = shallow(); + wrapper = shallow(); }); it('contains a button back to the sources list', () => { @@ -52,20 +55,14 @@ describe('SaveCustom', () => { let wrapper: ShallowWrapper; beforeAll(() => { - jest.clearAllMocks(); - setMockValues({ - ...mockValues, - sourceData: { - ...staticCustomSourceData, - serviceType: 'sharepoint-server', - configuration: { - ...staticCustomSourceData.configuration, - githubRepository: 'elastic/sharepoint-server-connector', - }, - }, - }); - - wrapper = shallow(); + wrapper = shallow( + + ); }); it('includes a link to provide feedback', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.tsx index 9e5e3ac2782ee5f..df62d2b2bdf1605 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_custom_source/save_custom.tsx @@ -21,12 +21,14 @@ import { EuiCallOut, EuiLink, } from '@elastic/eui'; + import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButtonTo } from '../../../../../../shared/react_router_helpers'; import { AppLogic } from '../../../../../app_logic'; import { SOURCES_PATH, getSourcesPath } from '../../../../../routes'; +import { SourceDataItem } from '../../../../../types'; import { CustomSourceDeployment } from '../../custom_source_deployment'; @@ -35,10 +37,14 @@ import { SAVE_CUSTOM_BODY1 as READY_TO_ACCEPT_REQUESTS_LABEL } from '../constant import { AddCustomSourceLogic } from './add_custom_source_logic'; -export const SaveCustom: React.FC = () => { - const { newCustomSource, sourceData } = useValues(AddCustomSourceLogic); +interface SaveCustomProps { + sourceData: SourceDataItem; +} + +export const SaveCustom: React.FC = ({ sourceData }) => { + const { newCustomSource } = useValues(AddCustomSourceLogic); const { isOrganization } = useValues(AppLogic); - const { serviceType, name, categories = [] } = sourceData; + const { serviceType, baseServiceType, name, categories = [] } = sourceData; return ( <> @@ -92,10 +98,10 @@ export const SaveCustom: React.FC = () => { - + - {serviceType !== 'custom' && ( + {baseServiceType && ( <> diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx index 2d8b5192fd3b10b..8f517b740b1525d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.test.tsx @@ -7,6 +7,7 @@ import '../../../../../../__mocks__/shallow_useeffect.mock'; import { setMockActions, setMockValues } from '../../../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../../../__mocks__/react_router'; import { sourceConfigData } from '../../../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -19,24 +20,15 @@ import { WorkplaceSearchPageTemplate, PersonalDashboardLayout, } from '../../../../../components/layout'; -import { staticSourceData } from '../../../source_data'; import { ExternalConnectorConfig } from './external_connector_config'; import { ExternalConnectorFormFields } from './external_connector_form_fields'; describe('ExternalConnectorConfig', () => { - const goBack = jest.fn(); - const onDeleteConfig = jest.fn(); const setExternalConnectorApiKey = jest.fn(); const setExternalConnectorUrl = jest.fn(); const saveExternalConnectorConfig = jest.fn(); - const props = { - sourceData: staticSourceData[0], - goBack, - onDeleteConfig, - }; - const values = { sourceConfigData, buttonLoading: false, @@ -48,37 +40,47 @@ describe('ExternalConnectorConfig', () => { }; beforeEach(() => { + jest.clearAllMocks(); setMockActions({ setExternalConnectorApiKey, setExternalConnectorUrl, saveExternalConnectorConfig, }); setMockValues({ ...values }); + mockUseParams.mockReturnValue({}); + }); + + it('returns null if there is no matching source data for the service type', () => { + mockUseParams.mockReturnValue({ baseServiceType: 'doesnt_exist' }); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(EuiSteps)).toHaveLength(1); expect(wrapper.find(EuiSteps).dive().find(ExternalConnectorFormFields)).toHaveLength(1); }); it('renders organizstion layout', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(1); }); it('should show correct layout for personal dashboard', () => { setMockValues({ ...values, isOrganization: false }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(WorkplaceSearchPageTemplate)).toHaveLength(0); expect(wrapper.find(PersonalDashboardLayout)).toHaveLength(1); }); it('handles form submission', () => { - const wrapper = shallow(); + const wrapper = shallow(); const preventDefault = jest.fn(); wrapper.find('form').simulate('submit', { preventDefault }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx index 5a2558f141ea05a..0b4e34f47103b27 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_config.tsx @@ -7,11 +7,12 @@ import React, { FormEvent } from 'react'; +import { useParams } from 'react-router-dom'; + import { useActions, useValues } from 'kea'; import { EuiButton, - EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiForm, @@ -26,56 +27,41 @@ import { PersonalDashboardLayout, WorkplaceSearchPageTemplate, } from '../../../../../components/layout'; -import { NAV, REMOVE_BUTTON } from '../../../../../constants'; -import { SourceDataItem } from '../../../../../types'; - -import { staticExternalSourceData } from '../../../source_data'; +import { NAV } from '../../../../../constants'; +import { getSourceData } from '../../../source_data'; import { AddSourceHeader } from '../add_source_header'; import { ConfigDocsLinks } from '../config_docs_links'; -import { OAUTH_SAVE_CONFIG_BUTTON, OAUTH_BACK_BUTTON } from '../constants'; +import { OAUTH_SAVE_CONFIG_BUTTON } from '../constants'; import { ExternalConnectorDocumentation } from './external_connector_documentation'; import { ExternalConnectorFormFields } from './external_connector_form_fields'; import { ExternalConnectorLogic } from './external_connector_logic'; -interface SaveConfigProps { - sourceData: SourceDataItem; - goBack?: () => void; - onDeleteConfig?: () => void; -} - -export const ExternalConnectorConfig: React.FC = ({ - sourceData, - goBack, - onDeleteConfig, -}) => { - const serviceType = 'external'; +export const ExternalConnectorConfig: React.FC = () => { + const { baseServiceType } = useParams<{ baseServiceType?: string }>(); + const sourceData = getSourceData('external', baseServiceType); const { saveExternalConnectorConfig } = useActions(ExternalConnectorLogic); - const { - formDisabled, - buttonLoading, - externalConnectorUrl, - externalConnectorApiKey, - sourceConfigData, - urlValid, - } = useValues(ExternalConnectorLogic); + const { formDisabled, buttonLoading, externalConnectorUrl, externalConnectorApiKey, urlValid } = + useValues(ExternalConnectorLogic); const handleFormSubmission = (e: FormEvent) => { e.preventDefault(); saveExternalConnectorConfig({ url: externalConnectorUrl, apiKey: externalConnectorApiKey }); }; - const { name, categories } = sourceConfigData; - const { - configuration: { applicationLinkTitle, applicationPortalUrl }, - } = sourceData; const { isOrganization } = useValues(AppLogic); + if (!sourceData) { + return null; + } + const { - configuration: { documentationUrl }, - } = staticExternalSourceData; + name, + categories = [], + configuration: { applicationLinkTitle, applicationPortalUrl, documentationUrl }, + } = sourceData; const saveButton = ( @@ -83,22 +69,10 @@ export const ExternalConnectorConfig: React.FC = ({ ); - const deleteButton = ( - - {REMOVE_BUTTON} - - ); - - const backButton = {OAUTH_BACK_BUTTON}; - const formActions = ( {saveButton} - - {goBack && backButton} - {onDeleteConfig && deleteButton} - ); @@ -132,11 +106,17 @@ export const ExternalConnectorConfig: React.FC = ({ }, ]; - const header = ; + const header = ( + + ); const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; return ( - + {header} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts index fb09695a3529d85..0603b59cc75b0e8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.test.ts @@ -36,10 +36,6 @@ describe('ExternalConnectorLogic', () => { formDisabled: true, externalConnectorUrl: '', externalConnectorApiKey: '', - sourceConfigData: { - name: '', - categories: [], - }, urlValid: true, showInsecureUrlCallout: false, insecureUrl: true, @@ -52,7 +48,6 @@ describe('ExternalConnectorLogic', () => { formDisabled: false, insecureUrl: false, dataLoading: false, - sourceConfigData, }; beforeEach(() => { @@ -87,7 +82,6 @@ describe('ExternalConnectorLogic', () => { it('saves the source config', () => { expect(ExternalConnectorLogic.values).toEqual({ ...DEFAULT_VALUES_SUCCESS, - sourceConfigData, }); }); @@ -104,7 +98,6 @@ describe('ExternalConnectorLogic', () => { ...DEFAULT_VALUES_SUCCESS, externalConnectorUrl: '', insecureUrl: true, - sourceConfigData: newSourceConfigData, }); }); it('sets undefined api key to empty string', () => { @@ -119,7 +112,6 @@ describe('ExternalConnectorLogic', () => { expect(ExternalConnectorLogic.values).toEqual({ ...DEFAULT_VALUES_SUCCESS, externalConnectorApiKey: '', - sourceConfigData: newSourceConfigData, }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts index d1e4cf7f4f008a9..e36b790edd8e982 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_external_connector/external_connector_logic.ts @@ -48,7 +48,6 @@ export interface ExternalConnectorValues { externalConnectorApiKey: string; externalConnectorUrl: string; urlValid: boolean; - sourceConfigData: SourceConfigData | Pick; insecureUrl: boolean; showInsecureUrlCallout: boolean; } @@ -107,12 +106,6 @@ export const ExternalConnectorLogic = kea< setShowInsecureUrlCallout: (_, showCallout) => showCallout, }, ], - sourceConfigData: [ - { name: '', categories: [] }, - { - fetchExternalSourceSuccess: (_, sourceConfigData) => sourceConfigData, - }, - ], urlValid: [ true, { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx index a7cfa81d300212b..8811a68e4918112 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.test.tsx @@ -11,6 +11,7 @@ import { setMockActions, setMockValues, } from '../../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../../__mocks__/react_router'; import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -22,13 +23,9 @@ import { PersonalDashboardLayout, } from '../../../../components/layout'; -import { staticSourceData } from '../../source_data'; - import { AddSource } from './add_source'; import { AddSourceSteps } from './add_source_logic'; import { ConfigCompleted } from './config_completed'; -import { ConfigurationChoice } from './configuration_choice'; -import { ConfigurationIntro } from './configuration_intro'; import { ConfigureOauth } from './configure_oauth'; import { ConnectInstance } from './connect_instance'; import { Reauthenticate } from './reauthenticate'; @@ -36,7 +33,7 @@ import { SaveConfig } from './save_config'; describe('AddSourceList', () => { const { navigateToUrl } = mockKibanaValues; - const initializeAddSource = jest.fn(); + const getSourceConfigData = jest.fn(); const setAddSourceStep = jest.fn(); const saveSourceConfig = jest.fn((_, setConfigCompletedStep) => { setConfigCompletedStep(); @@ -47,7 +44,7 @@ describe('AddSourceList', () => { const resetSourcesState = jest.fn(); const mockValues = { - addSourceCurrentStep: AddSourceSteps.ConfigIntroStep, + addSourceCurrentStep: null, sourceConfigData, dataLoading: false, newCustomSource: {}, @@ -56,68 +53,29 @@ describe('AddSourceList', () => { }; beforeEach(() => { + jest.clearAllMocks(); setMockActions({ - initializeAddSource, + getSourceConfigData, setAddSourceStep, saveSourceConfig, createContentSource, resetSourcesState, }); setMockValues(mockValues); - }); - - it('renders default state', () => { - const wrapper = shallow(); - wrapper.find(ConfigurationIntro).prop('advanceStep')(); - - expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); - expect(initializeAddSource).toHaveBeenCalled(); - }); - - it('renders default state correctly when there are multiple connector options', () => { - const wrapper = shallow( - - ); - wrapper.find(ConfigurationIntro).prop('advanceStep')(); - - expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.ChoiceStep); - }); - - it('renders default state correctly when there are multiple connector options but external connector is configured', () => { - setMockValues({ ...mockValues, externalConfigured: true }); - const wrapper = shallow( - - ); - wrapper.find(ConfigurationIntro).prop('advanceStep')(); - - expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); + mockUseParams.mockReturnValue({ serviceType: 'confluence_cloud' }); }); describe('layout', () => { it('renders the default workplace search layout when on an organization view', () => { setMockValues({ ...mockValues, isOrganization: true }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); }); it('renders the personal dashboard layout when not in an organization', () => { setMockValues({ ...mockValues, isOrganization: false }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.type()).toEqual(PersonalDashboardLayout); }); @@ -125,7 +83,7 @@ describe('AddSourceList', () => { it('renders a breadcrumb fallback while data is loading', () => { setMockValues({ ...mockValues, dataLoading: true, sourceConfigData: {} }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.prop('pageChrome')).toEqual(['Sources', 'Add Source', '...']); }); @@ -135,26 +93,24 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.ConfigCompletedStep, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(ConfigCompleted).prop('showFeedbackLink')).toEqual(false); wrapper.find(ConfigCompleted).prop('advanceStep')(); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); }); it('renders Config Completed step with feedback for external connectors', () => { + mockUseParams.mockReturnValue({ serviceType: 'external' }); setMockValues({ ...mockValues, sourceConfigData: { ...sourceConfigData, serviceType: 'external' }, addSourceCurrentStep: AddSourceSteps.ConfigCompletedStep, }); - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper.find(ConfigCompleted).prop('showFeedbackLink')).toEqual(true); + wrapper.find(ConfigCompleted).prop('advanceStep')(); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); }); @@ -163,13 +119,13 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.SaveConfigStep, }); - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); saveConfig.prop('advanceStep')(); - saveConfig.prop('goBackStep')!(); - - expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.ConfigIntroStep); expect(saveSourceConfig).toHaveBeenCalled(); + + saveConfig.prop('goBackStep')!(); + expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/intro'); }); it('renders Connect Instance step', () => { @@ -178,10 +134,11 @@ describe('AddSourceList', () => { sourceConfigData, addSourceCurrentStep: AddSourceSteps.ConnectInstanceStep, }); - const wrapper = shallow(); + + const wrapper = shallow(); wrapper.find(ConnectInstance).prop('onFormCreated')('foo'); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); + expect(navigateToUrl).toHaveBeenCalledWith('/sources'); }); it('renders Configure Oauth step', () => { @@ -189,11 +146,11 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.ConfigureOauthStep, }); - const wrapper = shallow(); + const wrapper = shallow(); wrapper.find(ConfigureOauth).prop('onFormCreated')('foo'); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/confluence_cloud/connect'); + expect(navigateToUrl).toHaveBeenCalledWith('/sources'); }); it('renders Reauthenticate step', () => { @@ -201,23 +158,8 @@ describe('AddSourceList', () => { ...mockValues, addSourceCurrentStep: AddSourceSteps.ReauthenticateStep, }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find(Reauthenticate)).toHaveLength(1); }); - - it('renders Config Choice step', () => { - setMockValues({ - ...mockValues, - addSourceCurrentStep: AddSourceSteps.ChoiceStep, - }); - const wrapper = shallow(); - const advance = wrapper.find(ConfigurationChoice).prop('goToInternalStep'); - expect(advance).toBeDefined(); - if (advance) { - advance(); - } - - expect(setAddSourceStep).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); - }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx index 4bdf8db217a7b38..5b992703def61fb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source.tsx @@ -7,29 +7,28 @@ import React, { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; + import { useActions, useValues } from 'kea'; import { i18n } from '@kbn/i18n'; import { flashSuccessToast } from '../../../../../shared/flash_messages'; import { KibanaLogic } from '../../../../../shared/kibana'; +import { LicensingLogic } from '../../../../../shared/licensing'; import { AppLogic } from '../../../../app_logic'; import { WorkplaceSearchPageTemplate, PersonalDashboardLayout, } from '../../../../components/layout'; import { NAV } from '../../../../constants'; -import { SOURCES_PATH, getSourcesPath, getAddPath } from '../../../../routes'; - -import { hasMultipleConnectorOptions } from '../../../../utils'; +import { SOURCES_PATH, getSourcesPath, getAddPath, ADD_SOURCE_PATH } from '../../../../routes'; -import { SourcesLogic } from '../../sources_logic'; +import { getSourceData } from '../../source_data'; import { AddSourceHeader } from './add_source_header'; -import { AddSourceLogic, AddSourceProps, AddSourceSteps } from './add_source_logic'; +import { AddSourceLogic, AddSourceSteps } from './add_source_logic'; import { ConfigCompleted } from './config_completed'; -import { ConfigurationChoice } from './configuration_choice'; -import { ConfigurationIntro } from './configuration_intro'; import { ConfigureOauth } from './configure_oauth'; import { ConnectInstance } from './connect_instance'; import { Reauthenticate } from './reauthenticate'; @@ -37,27 +36,42 @@ import { SaveConfig } from './save_config'; import './add_source.scss'; -export const AddSource: React.FC = (props) => { - const { initializeAddSource, setAddSourceStep, saveSourceConfig, resetSourceState } = - useActions(AddSourceLogic); - const { addSourceCurrentStep, sourceConfigData, dataLoading } = useValues(AddSourceLogic); - const { name, categories, needsPermissions, accountContextOnly, privateSourcesEnabled } = - sourceConfigData; - const { serviceType, configuration, features, objTypes } = props.sourceData; - const addPath = getAddPath(serviceType); +export const AddSource: React.FC = () => { + const { serviceType, initialStep } = useParams<{ serviceType: string; initialStep?: string }>(); + const addSourceLogic = AddSourceLogic({ serviceType, initialStep }); + const { getSourceConfigData, setAddSourceStep, saveSourceConfig, resetSourceState } = + useActions(addSourceLogic); + const { addSourceCurrentStep, sourceConfigData, dataLoading } = useValues(addSourceLogic); const { isOrganization } = useValues(AppLogic); - const { externalConfigured } = useValues(SourcesLogic); + const { hasPlatinumLicense } = useValues(LicensingLogic); + const { navigateToUrl } = useValues(KibanaLogic); useEffect(() => { - initializeAddSource(props); + getSourceConfigData(); return resetSourceState; - }, []); + }, [serviceType]); + + const sourceData = getSourceData(serviceType); + + if (!sourceData) { + return null; + } + + const { configuration, features, objTypes } = sourceData; + + const { name, categories, needsPermissions, accountContextOnly, privateSourcesEnabled } = + sourceConfigData; + + if (!hasPlatinumLicense && accountContextOnly) { + navigateToUrl(getSourcesPath(ADD_SOURCE_PATH, isOrganization)); + } - const goToConfigurationIntro = () => setAddSourceStep(AddSourceSteps.ConfigIntroStep); - const goToSaveConfig = () => setAddSourceStep(AddSourceSteps.SaveConfigStep); + const goToConfigurationIntro = () => + KibanaLogic.values.navigateToUrl( + `${getSourcesPath(getAddPath(serviceType), isOrganization)}/intro` + ); const setConfigCompletedStep = () => setAddSourceStep(AddSourceSteps.ConfigCompletedStep); const goToConfigCompleted = () => saveSourceConfig(false, setConfigCompletedStep); - const goToChoice = () => setAddSourceStep(AddSourceSteps.ChoiceStep); const FORM_SOURCE_ADDED_SUCCESS_MESSAGE = i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.formSourceAddedSuccessMessage', { @@ -66,11 +80,7 @@ export const AddSource: React.FC = (props) => { } ); - const goToConnectInstance = () => { - setAddSourceStep(AddSourceSteps.ConnectInstanceStep); - KibanaLogic.values.navigateToUrl(`${getSourcesPath(addPath, isOrganization)}/connect`); - }; - + const goToConnectInstance = () => setAddSourceStep(AddSourceSteps.ConnectInstanceStep); const goToFormSourceCreated = () => { KibanaLogic.values.navigateToUrl(`${getSourcesPath(SOURCES_PATH, isOrganization)}`); flashSuccessToast(FORM_SOURCE_ADDED_SUCCESS_MESSAGE); @@ -81,18 +91,6 @@ export const AddSource: React.FC = (props) => { return ( - {addSourceCurrentStep === AddSourceSteps.ConfigIntroStep && ( - - )} {addSourceCurrentStep === AddSourceSteps.SaveConfigStep && ( = (props) => { {addSourceCurrentStep === AddSourceSteps.ReauthenticateStep && ( )} - {addSourceCurrentStep === AddSourceSteps.ChoiceStep && ( - - )} ); }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.test.tsx new file mode 100644 index 000000000000000..75b45da2b38b10a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.test.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockValues, mockKibanaValues } from '../../../../../__mocks__/kea_logic'; + +import { mockUseParams } from '../../../../../__mocks__/react_router'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; + +import { getSourceData } from '../../source_data'; + +import { AddSourceChoice } from './add_source_choice'; +import { ConfigurationChoice } from './configuration_choice'; + +describe('AddSourceChoice', () => { + const { navigateToUrl } = mockKibanaValues; + + const mockValues = { + isOrganization: true, + hasPlatinumLicense: true, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseParams.mockReturnValue({ serviceType: 'share_point' }); + }); + + it('returns null if there is no matching source data for the service type', () => { + mockUseParams.mockReturnValue({ serviceType: 'doesnt_exist' }); + setMockValues(mockValues); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('redirects to root add source path if user does not have a platinum license and the service is account context only', () => { + mockUseParams.mockReturnValue({ serviceType: 'slack' }); + setMockValues({ ...mockValues, hasPlatinumLicense: false }); + + shallow(); + + expect(navigateToUrl).toHaveBeenCalledWith('/sources/add'); + }); + + describe('layout', () => { + it('renders the default workplace search layout when on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: true }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); + }); + + it('renders the personal dashboard layout when not in an organization', () => { + setMockValues({ ...mockValues, isOrganization: false }); + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + }); + }); + + it('renders Config Choice step', () => { + setMockValues(mockValues); + const wrapper = shallow(); + + expect(wrapper.find(ConfigurationChoice).prop('sourceData')).toEqual( + getSourceData('share_point') + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.tsx new file mode 100644 index 000000000000000..1034d207c990783 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_choice.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useParams } from 'react-router-dom'; + +import { useValues } from 'kea'; + +import { KibanaLogic } from '../../../../../shared/kibana'; +import { LicensingLogic } from '../../../../../shared/licensing'; + +import { AppLogic } from '../../../../app_logic'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; +import { NAV } from '../../../../constants'; + +import { getSourcesPath, ADD_SOURCE_PATH } from '../../../../routes'; + +import { getSourceData } from '../../source_data'; + +import { ConfigurationChoice } from './configuration_choice'; + +import './add_source.scss'; + +export const AddSourceChoice: React.FC = () => { + const { serviceType } = useParams<{ serviceType: string }>(); + const sourceData = getSourceData(serviceType); + + const { isOrganization } = useValues(AppLogic); + const { hasPlatinumLicense } = useValues(LicensingLogic); + const { navigateToUrl } = useValues(KibanaLogic); + + if (!sourceData) { + return null; + } + + const { name, accountContextOnly } = sourceData; + + if (!hasPlatinumLicense && accountContextOnly) { + navigateToUrl(getSourcesPath(ADD_SOURCE_PATH, isOrganization)); + } + + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; + + return ( + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.test.tsx new file mode 100644 index 000000000000000..a7eeadf3a615e39 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import '../../../../../__mocks__/shallow_useeffect.mock'; +import { setMockValues } from '../../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../../__mocks__/react_router'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; + +import { AddSourceIntro } from './add_source_intro'; +import { ConfigurationIntro } from './configuration_intro'; + +describe('AddSourceList', () => { + const mockValues = { + isOrganization: true, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseParams.mockReturnValue({ serviceType: 'share_point' }); + }); + + it('returns null if there is no matching source data for the service type', () => { + mockUseParams.mockReturnValue({ serviceType: 'doesnt_exist' }); + setMockValues(mockValues); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('sends the user to a choice view when there are multiple connector options', () => { + setMockValues(mockValues); + + const wrapper = shallow(); + + expect(wrapper.find(ConfigurationIntro).prop('advanceStepTo')).toEqual( + '/sources/add/share_point/choice' + ); + }); + + it('sends the user to the add source view by default', () => { + mockUseParams.mockReturnValue({ serviceType: 'slack' }); + setMockValues(mockValues); + + const wrapper = shallow(); + + expect(wrapper.find(ConfigurationIntro).prop('advanceStepTo')).toEqual('/sources/add/slack/'); + }); + + describe('layout', () => { + it('renders the default workplace search layout when on an organization view', () => { + setMockValues({ ...mockValues, isOrganization: true }); + + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(WorkplaceSearchPageTemplate); + }); + + it('renders the personal dashboard layout when not in an organization', () => { + setMockValues({ ...mockValues, isOrganization: false }); + + const wrapper = shallow(); + + expect(wrapper.type()).toEqual(PersonalDashboardLayout); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.tsx new file mode 100644 index 000000000000000..b375f04a27f0ff5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_intro.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { useParams } from 'react-router-dom'; + +import { useValues } from 'kea'; + +import { KibanaLogic } from '../../../../../shared/kibana'; +import { LicensingLogic } from '../../../../../shared/licensing'; + +import { AppLogic } from '../../../../app_logic'; +import { + WorkplaceSearchPageTemplate, + PersonalDashboardLayout, +} from '../../../../components/layout'; +import { NAV } from '../../../../constants'; +import { getSourcesPath, ADD_SOURCE_PATH, getAddPath } from '../../../../routes'; + +import { getSourceData, hasMultipleConnectorOptions } from '../../source_data'; + +import { AddSourceHeader } from './add_source_header'; +import { ConfigurationIntro } from './configuration_intro'; + +import './add_source.scss'; + +export const AddSourceIntro: React.FC = () => { + const { serviceType } = useParams<{ serviceType: string }>(); + const sourceData = getSourceData(serviceType); + + const { isOrganization } = useValues(AppLogic); + const { hasPlatinumLicense } = useValues(LicensingLogic); + const { navigateToUrl } = useValues(KibanaLogic); + + if (!sourceData) { + return null; + } + + const { name, categories = [], accountContextOnly } = sourceData; + + if (!hasPlatinumLicense && accountContextOnly) { + navigateToUrl(getSourcesPath(ADD_SOURCE_PATH, isOrganization)); + } + + const header = ; + const Layout = isOrganization ? WorkplaceSearchPageTemplate : PersonalDashboardLayout; + const to = + `${getSourcesPath(getAddPath(serviceType), isOrganization)}/` + + (hasMultipleConnectorOptions(serviceType) ? 'choice' : ''); + return ( + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts index 88ca96b8c0fbf00..3224628e72c73e4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.test.ts @@ -15,7 +15,6 @@ import { sourceConfigData } from '../../../../__mocks__/content_sources.mock'; import { nextTick } from '@kbn/test-jest-helpers'; -import { docLinks } from '../../../../../shared/doc_links'; import { itShowsServerErrorAsFlashMessage } from '../../../../../test_helpers'; jest.mock('../../../../app_logic', () => ({ @@ -23,10 +22,9 @@ jest.mock('../../../../app_logic', () => ({ })); import { AppLogic } from '../../../../app_logic'; -import { SOURCE_NAMES, SOURCE_OBJ_TYPES } from '../../../../constants'; import { SOURCES_PATH, PRIVATE_SOURCES_PATH, getSourcesPath } from '../../../../routes'; -import { FeatureIds } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; +import { staticSourceData } from '../../source_data'; import { SourcesLogic } from '../../sources_logic'; import { ExternalConnectorLogic } from './add_external_connector/external_connector_logic'; @@ -37,7 +35,6 @@ import { SourceConnectData, OrganizationsMap, AddSourceValues, - AddSourceProps, } from './add_source_logic'; describe('AddSourceLogic', () => { @@ -47,8 +44,7 @@ describe('AddSourceLogic', () => { const { clearFlashMessages, flashAPIErrors, setErrorMessage } = mockFlashMessageHelpers; const DEFAULT_VALUES: AddSourceValues = { - addSourceCurrentStep: AddSourceSteps.ConfigIntroStep, - addSourceProps: {} as AddSourceProps, + addSourceCurrentStep: null, dataLoading: true, sectionLoading: true, buttonLoading: false, @@ -62,11 +58,11 @@ describe('AddSourceLogic', () => { sourceConfigData: {} as SourceConfigData, sourceConnectData: {} as SourceConnectData, oauthConfigCompleted: false, - currentServiceType: '', githubOrganizations: [], selectedGithubOrganizationsMap: {} as OrganizationsMap, selectedGithubOrganizations: [], preContentSourceId: '', + sourceData: staticSourceData[0], }; const sourceConnectData = { @@ -79,40 +75,13 @@ describe('AddSourceLogic', () => { serviceType: 'github', githubOrganizations: ['foo', 'bar'], }; - const DEFAULT_SERVICE_TYPE = { - name: SOURCE_NAMES.BOX, - iconName: SOURCE_NAMES.BOX, - serviceType: 'box', - configuration: { - isPublicKey: false, - hasOauthRedirect: true, - needsBaseUrl: false, - documentationUrl: docLinks.workplaceSearchBox, - applicationPortalUrl: 'https://app.box.com/developers/console', - }, - objTypes: [SOURCE_OBJ_TYPES.FOLDERS, SOURCE_OBJ_TYPES.ALL_FILES], - features: { - basicOrgContext: [ - FeatureIds.SyncFrequency, - FeatureIds.SyncedItems, - FeatureIds.GlobalAccessPermissions, - ], - basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions], - platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems], - platinumPrivateContext: [ - FeatureIds.Private, - FeatureIds.SyncFrequency, - FeatureIds.SyncedItems, - ], - }, - accountContextOnly: false, - }; + const DEFAULT_SERVICE_TYPE = 'box'; beforeEach(() => { jest.clearAllMocks(); ExternalConnectorLogic.mount(); SourcesLogic.mount(); - mount(); + mount({}, { serviceType: 'box' }); }); it('has expected default values', () => { @@ -215,7 +184,6 @@ describe('AddSourceLogic', () => { oauthConfigCompleted: true, dataLoading: false, sectionLoading: false, - currentServiceType: config.serviceType, githubOrganizations: config.githubOrganizations, }); }); @@ -286,140 +254,90 @@ describe('AddSourceLogic', () => { }); describe('listeners', () => { - it('initializeAddSource', () => { - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE }; - const getSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'getSourceConfigData'); - const setAddSourcePropsSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceProps'); - - AddSourceLogic.actions.initializeAddSource(addSourceProps); - - expect(setAddSourcePropsSpy).toHaveBeenCalledWith({ addSourceProps }); - expect(getSourceConfigDataSpy).toHaveBeenCalledWith('box', addSourceProps); - }); - describe('setFirstStep', () => { - it('sets intro as first step', () => { + it('sets save config as first step if unconfigured', () => { + mount( + { + sourceConfigData: { + ...sourceConfigData, + configured: false, + }, + }, + { serviceType: DEFAULT_SERVICE_TYPE } + ); const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE }; - AddSourceLogic.actions.setFirstStep(addSourceProps); - expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConfigIntroStep); + AddSourceLogic.actions.setFirstStep(); + + expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); }); + it('sets connect as first step', () => { + mount({ sourceConfigData }, { serviceType: DEFAULT_SERVICE_TYPE, initialStep: 'connect' }); const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, connect: true }; - AddSourceLogic.actions.setFirstStep(addSourceProps); + + AddSourceLogic.actions.setFirstStep(); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); }); it('sets configure as first step', () => { + mount( + { sourceConfigData }, + { serviceType: DEFAULT_SERVICE_TYPE, initialStep: 'configure' } + ); const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, configure: true }; - AddSourceLogic.actions.setFirstStep(addSourceProps); + + AddSourceLogic.actions.setFirstStep(); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConfigureOauthStep); }); - it('sets reAuthenticate as first step', () => { + it('sets reauthenticate as first step', () => { + mount( + { sourceConfigData }, + { serviceType: DEFAULT_SERVICE_TYPE, initialStep: 'reauthenticate' } + ); const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE, reAuthenticate: true }; - AddSourceLogic.actions.setFirstStep(addSourceProps); + + AddSourceLogic.actions.setFirstStep(); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ReauthenticateStep); }); - it('sets SaveConfig as first step for external connectors', () => { - const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { - sourceData: { - ...DEFAULT_SERVICE_TYPE, - serviceType: 'external', - }, - }; - AddSourceLogic.actions.setFirstStep(addSourceProps); - expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); - }); - it('sets SaveConfigStep for when external connector is available and configured', () => { - const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { - sourceData: { - ...DEFAULT_SERVICE_TYPE, - externalConnectorAvailable: true, - }, - }; - AddSourceLogic.actions.setSourceConfigData({ - ...sourceConfigData, - serviceType: 'external', - configured: false, - }); - SourcesLogic.mount(); - SourcesLogic.actions.onInitializeSources({ - contentSources: [], - serviceTypes: [ - { - serviceType: 'external', + it('sets connect step if configured', () => { + mount( + { + sourceConfigData: { + ...sourceConfigData, configured: true, }, - ], - } as any); - AddSourceLogic.actions.setFirstStep(addSourceProps); - - expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.SaveConfigStep); - }); - it('sets Connect step when configured and external connector is available and configured', () => { - const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { - sourceData: { - ...DEFAULT_SERVICE_TYPE, - externalConnectorAvailable: true, - configured: true, }, - }; - AddSourceLogic.actions.setSourceConfigData({ - ...sourceConfigData, - serviceType: 'external', - configured: true, - }); - SourcesLogic.mount(); - SourcesLogic.actions.onInitializeSources({ - contentSources: [], - serviceTypes: [ - { - serviceType: 'external', - configured: true, - }, - ], - } as any); - AddSourceLogic.actions.setFirstStep(addSourceProps); + { serviceType: DEFAULT_SERVICE_TYPE } + ); + const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); + AddSourceLogic.actions.setFirstStep(); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); }); - it('sets Connect step when external and fully configured', () => { - const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); - const addSourceProps = { - sourceData: { - ...DEFAULT_SERVICE_TYPE, - serviceType: 'external', - }, - }; - AddSourceLogic.actions.setSourceConfigData({ - ...sourceConfigData, - configured: true, - serviceType: 'external', - configuredFields: { clientId: 'a', clientSecret: 'b' }, - }); - SourcesLogic.mount(); - SourcesLogic.actions.onInitializeSources({ - contentSources: [], - serviceTypes: [ - { + + it('sets connect step if external connector has client id and secret', () => { + mount( + { + sourceConfigData: { + ...sourceConfigData, serviceType: 'external', - configured: true, + configuredFields: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }, }, - ], - } as any); - AddSourceLogic.actions.setFirstStep(addSourceProps); + }, + { serviceType: DEFAULT_SERVICE_TYPE } + ); + const setAddSourceStepSpy = jest.spyOn(AddSourceLogic.actions, 'setAddSourceStep'); + + AddSourceLogic.actions.setFirstStep(); expect(setAddSourceStepSpy).toHaveBeenCalledWith(AddSourceSteps.ConnectInstanceStep); }); @@ -541,30 +459,33 @@ describe('AddSourceLogic', () => { const setSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'setSourceConfigData'); http.get.mockReturnValue(Promise.resolve(sourceConfigData)); - AddSourceLogic.actions.getSourceConfigData('github'); + AddSourceLogic.actions.getSourceConfigData(); + await nextTick(); + expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/org/settings/connectors/github' + '/internal/workplace_search/org/settings/connectors/box' ); - await nextTick(); expect(setSourceConfigDataSpy).toHaveBeenCalledWith(sourceConfigData); }); + it('calls API and sets values and calls setFirstStep if AddSourceProps is provided', async () => { const setSourceConfigDataSpy = jest.spyOn(AddSourceLogic.actions, 'setSourceConfigData'); const setFirstStepSpy = jest.spyOn(AddSourceLogic.actions, 'setFirstStep'); - const addSourceProps = { sourceData: DEFAULT_SERVICE_TYPE }; + http.get.mockReturnValue(Promise.resolve(sourceConfigData)); - AddSourceLogic.actions.getSourceConfigData('github', addSourceProps); + AddSourceLogic.actions.getSourceConfigData(); + await nextTick(); + expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/org/settings/connectors/github' + '/internal/workplace_search/org/settings/connectors/box' ); - await nextTick(); expect(setSourceConfigDataSpy).toHaveBeenCalledWith(sourceConfigData); - expect(setFirstStepSpy).toHaveBeenCalledWith(addSourceProps); + expect(setFirstStepSpy).toHaveBeenCalled(); }); itShowsServerErrorAsFlashMessage(http.get, () => { - AddSourceLogic.actions.getSourceConfigData('github'); + AddSourceLogic.actions.getSourceConfigData(); }); }); @@ -579,7 +500,7 @@ describe('AddSourceLogic', () => { ); http.get.mockReturnValue(Promise.resolve(sourceConnectData)); - AddSourceLogic.actions.getSourceConnectData('github', successCallback); + AddSourceLogic.actions.getSourceConnectData(successCallback); const query = { index_permissions: false, @@ -588,7 +509,7 @@ describe('AddSourceLogic', () => { expect(clearFlashMessages).toHaveBeenCalled(); expect(AddSourceLogic.values.buttonLoading).toEqual(true); expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/org/sources/github/prepare', + '/internal/workplace_search/org/sources/box/prepare', { query, } @@ -602,7 +523,7 @@ describe('AddSourceLogic', () => { it('passes query params', () => { AddSourceLogic.actions.setSourceSubdomainValue('subdomain'); AddSourceLogic.actions.setSourceIndexPermissionsValue(true); - AddSourceLogic.actions.getSourceConnectData('github', successCallback); + AddSourceLogic.actions.getSourceConnectData(successCallback); const query = { index_permissions: true, @@ -610,7 +531,7 @@ describe('AddSourceLogic', () => { }; expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/org/sources/github/prepare', + '/internal/workplace_search/org/sources/box/prepare', { query, } @@ -618,7 +539,7 @@ describe('AddSourceLogic', () => { }); itShowsServerErrorAsFlashMessage(http.get, () => { - AddSourceLogic.actions.getSourceConnectData('github', successCallback); + AddSourceLogic.actions.getSourceConnectData(successCallback); }); }); @@ -833,7 +754,7 @@ describe('AddSourceLogic', () => { const successCallback = jest.fn(); const errorCallback = jest.fn(); - const serviceType = 'zendesk'; + const serviceType = 'box'; const login = 'login'; const password = 'password'; const indexPermissions = false; @@ -859,7 +780,7 @@ describe('AddSourceLogic', () => { const setButtonNotLoadingSpy = jest.spyOn(AddSourceLogic.actions, 'setButtonNotLoading'); http.post.mockReturnValue(Promise.resolve()); - AddSourceLogic.actions.createContentSource(serviceType, successCallback, errorCallback); + AddSourceLogic.actions.createContentSource(successCallback, errorCallback); expect(clearFlashMessages).toHaveBeenCalled(); expect(AddSourceLogic.values.buttonLoading).toEqual(true); @@ -875,7 +796,7 @@ describe('AddSourceLogic', () => { const setButtonNotLoadingSpy = jest.spyOn(AddSourceLogic.actions, 'setButtonNotLoading'); http.post.mockReturnValue(Promise.reject('this is an error')); - AddSourceLogic.actions.createContentSource(serviceType, successCallback, errorCallback); + AddSourceLogic.actions.createContentSource(successCallback, errorCallback); await nextTick(); expect(setButtonNotLoadingSpy).toHaveBeenCalled(); @@ -891,10 +812,10 @@ describe('AddSourceLogic', () => { }); it('getSourceConnectData', () => { - AddSourceLogic.actions.getSourceConnectData('github', jest.fn()); + AddSourceLogic.actions.getSourceConnectData(jest.fn()); expect(http.get).toHaveBeenCalledWith( - '/internal/workplace_search/account/sources/github/prepare', + '/internal/workplace_search/account/sources/box/prepare', { query: {} } ); }); @@ -915,10 +836,10 @@ describe('AddSourceLogic', () => { }); it('createContentSource', () => { - AddSourceLogic.actions.createContentSource('github', jest.fn()); + AddSourceLogic.actions.createContentSource(jest.fn()); expect(http.post).toHaveBeenCalledWith('/internal/workplace_search/account/create_source', { - body: JSON.stringify({ service_type: 'github' }), + body: JSON.stringify({ service_type: 'box' }), }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts index 97a58966ad76abc..a087f1b78571b75 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/add_source_logic.ts @@ -23,6 +23,7 @@ import { AppLogic } from '../../../../app_logic'; import { SOURCES_PATH, PRIVATE_SOURCES_PATH, getSourcesPath, getAddPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; import { PERSONAL_DASHBOARD_SOURCE_ERROR } from '../../constants'; +import { getSourceData } from '../../source_data'; import { SourcesLogic } from '../../sources_logic'; import { @@ -31,20 +32,16 @@ import { } from './add_external_connector/external_connector_logic'; export interface AddSourceProps { - sourceData: SourceDataItem; - connect?: boolean; - configure?: boolean; - reAuthenticate?: boolean; + serviceType: string; + initialStep?: string; } export enum AddSourceSteps { - ConfigIntroStep = 'Config Intro', SaveConfigStep = 'Save Config', ConfigCompletedStep = 'Config Completed', ConnectInstanceStep = 'Connect Instance', ConfigureOauthStep = 'Configure Oauth', ReauthenticateStep = 'Reauthenticate', - ChoiceStep = 'Choice', } export interface OauthParams { @@ -57,10 +54,6 @@ export interface OauthParams { } export interface AddSourceActions { - initializeAddSource: (addSourceProps: AddSourceProps) => { addSourceProps: AddSourceProps }; - setAddSourceProps: ({ addSourceProps }: { addSourceProps: AddSourceProps }) => { - addSourceProps: AddSourceProps; - }; setAddSourceStep(addSourceCurrentStep: AddSourceSteps): AddSourceSteps; setSourceConfigData(sourceConfigData: SourceConfigData): SourceConfigData; setSourceConnectData(sourceConnectData: SourceConnectData): SourceConnectData; @@ -76,10 +69,9 @@ export interface AddSourceActions { setSelectedGithubOrganizations(option: string): string; resetSourceState(): void; createContentSource( - serviceType: string, successCallback: () => void, errorCallback?: () => void - ): { serviceType: string; successCallback(): void; errorCallback?(): void }; + ): { successCallback(): void; errorCallback?(): void }; saveSourceConfig( isUpdating: boolean, successCallback?: () => void @@ -89,24 +81,22 @@ export interface AddSourceActions { params: OauthParams, isOrganization: boolean ): { search: Search; params: OauthParams; isOrganization: boolean }; - getSourceConfigData( - serviceType: string, - addSourceProps?: AddSourceProps - ): { serviceType: string; addSourceProps: AddSourceProps | undefined }; - getSourceConnectData( - serviceType: string, - successCallback: (oauthUrl: string) => void - ): { serviceType: string; successCallback(oauthUrl: string): void }; + getSourceConfigData(): void; + getSourceConnectData(successCallback: (oauthUrl: string) => void): { + successCallback(oauthUrl: string): void; + }; getSourceReConnectData(sourceId: string): { sourceId: string }; getPreContentSourceConfigData(): void; setButtonNotLoading(): void; - setFirstStep(addSourceProps: AddSourceProps): { addSourceProps: AddSourceProps }; + setFirstStep(): void; } export interface SourceConfigData { serviceType: string; + baseServiceType?: string; name: string; configured: boolean; + externalConnectorServiceDescribed?: boolean; categories: string[]; needsPermissions?: boolean; privateSourcesEnabled: boolean; @@ -133,8 +123,7 @@ export interface OrganizationsMap { } export interface AddSourceValues { - addSourceProps: AddSourceProps; - addSourceCurrentStep: AddSourceSteps; + addSourceCurrentStep: AddSourceSteps | null; dataLoading: boolean; sectionLoading: boolean; buttonLoading: boolean; @@ -147,12 +136,12 @@ export interface AddSourceValues { indexPermissionsValue: boolean; sourceConfigData: SourceConfigData; sourceConnectData: SourceConnectData; - currentServiceType: string; githubOrganizations: string[]; selectedGithubOrganizationsMap: OrganizationsMap; selectedGithubOrganizations: string[]; preContentSourceId: string; oauthConfigCompleted: boolean; + sourceData: SourceDataItem | null; } interface PreContentSourceResponse { @@ -161,471 +150,436 @@ interface PreContentSourceResponse { githubOrganizations: string[]; } -export const AddSourceLogic = kea>({ - path: ['enterprise_search', 'workplace_search', 'add_source_logic'], - actions: { - initializeAddSource: (addSourceProps: AddSourceProps) => ({ addSourceProps }), - setAddSourceProps: ({ addSourceProps }: { addSourceProps: AddSourceProps }) => ({ - addSourceProps, - }), - setAddSourceStep: (addSourceCurrentStep: AddSourceSteps) => addSourceCurrentStep, - setSourceConfigData: (sourceConfigData: SourceConfigData) => sourceConfigData, - setSourceConnectData: (sourceConnectData: SourceConnectData) => sourceConnectData, - setClientIdValue: (clientIdValue: string) => clientIdValue, - setClientSecretValue: (clientSecretValue: string) => clientSecretValue, - setBaseUrlValue: (baseUrlValue: string) => baseUrlValue, - setSourceLoginValue: (loginValue: string) => loginValue, - setSourcePasswordValue: (passwordValue: string) => passwordValue, - setSourceSubdomainValue: (subdomainValue: string) => subdomainValue, - setSourceIndexPermissionsValue: (indexPermissionsValue: boolean) => indexPermissionsValue, - setPreContentSourceConfigData: (data: PreContentSourceResponse) => data, - setPreContentSourceId: (preContentSourceId: string) => preContentSourceId, - setSelectedGithubOrganizations: (option: string) => option, - getSourceConfigData: (serviceType: string, addSourceProps?: AddSourceProps) => ({ - serviceType, - addSourceProps, - }), - getSourceConnectData: (serviceType: string, successCallback: (oauthUrl: string) => string) => ({ - serviceType, - successCallback, - }), - getSourceReConnectData: (sourceId: string) => ({ sourceId }), - getPreContentSourceConfigData: () => true, - saveSourceConfig: (isUpdating: boolean, successCallback?: () => void) => ({ - isUpdating, - successCallback, +export const AddSourceLogic = kea>( + { + path: ['enterprise_search', 'workplace_search', 'add_source_logic'], + actions: { + setAddSourceStep: (addSourceCurrentStep: AddSourceSteps) => addSourceCurrentStep, + setSourceConfigData: (sourceConfigData: SourceConfigData) => sourceConfigData, + setSourceConnectData: (sourceConnectData: SourceConnectData) => sourceConnectData, + setClientIdValue: (clientIdValue: string) => clientIdValue, + setClientSecretValue: (clientSecretValue: string) => clientSecretValue, + setBaseUrlValue: (baseUrlValue: string) => baseUrlValue, + setSourceLoginValue: (loginValue: string) => loginValue, + setSourcePasswordValue: (passwordValue: string) => passwordValue, + setSourceSubdomainValue: (subdomainValue: string) => subdomainValue, + setSourceIndexPermissionsValue: (indexPermissionsValue: boolean) => indexPermissionsValue, + setPreContentSourceConfigData: (data: PreContentSourceResponse) => data, + setPreContentSourceId: (preContentSourceId: string) => preContentSourceId, + setSelectedGithubOrganizations: (option: string) => option, + getSourceConfigData: () => true, + getSourceConnectData: (successCallback: (oauthUrl: string) => string) => ({ + successCallback, + }), + getSourceReConnectData: (sourceId: string) => ({ sourceId }), + getPreContentSourceConfigData: () => true, + saveSourceConfig: (isUpdating: boolean, successCallback?: () => void) => ({ + isUpdating, + successCallback, + }), + saveSourceParams: (search: Search, params: OauthParams, isOrganization: boolean) => ({ + search, + params, + isOrganization, + }), + createContentSource: (successCallback: () => void, errorCallback?: () => void) => ({ + successCallback, + errorCallback, + }), + resetSourceState: () => true, + setButtonNotLoading: () => true, + setFirstStep: () => true, + }, + reducers: ({ props }) => ({ + addSourceCurrentStep: [ + null, + { + setAddSourceStep: (_, addSourceCurrentStep) => addSourceCurrentStep, + }, + ], + sourceConfigData: [ + {} as SourceConfigData, + { + setSourceConfigData: (_, sourceConfigData) => sourceConfigData, + }, + ], + sourceConnectData: [ + {} as SourceConnectData, + { + setSourceConnectData: (_, sourceConnectData) => sourceConnectData, + }, + ], + dataLoading: [ + true, + { + setSourceConfigData: () => false, + resetSourceState: () => false, + setPreContentSourceConfigData: () => false, + getSourceConfigData: () => true, + }, + ], + buttonLoading: [ + false, + { + setButtonNotLoading: () => false, + setSourceConnectData: () => false, + setSourceConfigData: () => false, + resetSourceState: () => false, + saveSourceConfig: () => true, + getSourceConnectData: () => true, + createContentSource: () => true, + }, + ], + sectionLoading: [ + true, + { + getPreContentSourceConfigData: () => true, + setPreContentSourceConfigData: () => false, + }, + ], + clientIdValue: [ + '', + { + setClientIdValue: (_, clientIdValue) => clientIdValue, + setSourceConfigData: (_, { configuredFields: { clientId } }) => clientId || '', + resetSourceState: () => '', + }, + ], + clientSecretValue: [ + '', + { + setClientSecretValue: (_, clientSecretValue) => clientSecretValue, + setSourceConfigData: (_, { configuredFields: { clientSecret } }) => clientSecret || '', + resetSourceState: () => '', + }, + ], + baseUrlValue: [ + '', + { + setBaseUrlValue: (_, baseUrlValue) => baseUrlValue, + setSourceConfigData: (_, { configuredFields: { baseUrl } }) => baseUrl || '', + resetSourceState: () => '', + }, + ], + loginValue: [ + '', + { + setSourceLoginValue: (_, loginValue) => loginValue, + resetSourceState: () => '', + }, + ], + passwordValue: [ + '', + { + setSourcePasswordValue: (_, passwordValue) => passwordValue, + resetSourceState: () => '', + }, + ], + subdomainValue: [ + '', + { + setSourceSubdomainValue: (_, subdomainValue) => subdomainValue, + resetSourceState: () => '', + }, + ], + indexPermissionsValue: [ + false, + { + setSourceIndexPermissionsValue: (_, indexPermissionsValue) => indexPermissionsValue, + resetSourceState: () => false, + }, + ], + githubOrganizations: [ + [], + { + setPreContentSourceConfigData: (_, { githubOrganizations }) => githubOrganizations, + resetSourceState: () => [], + }, + ], + selectedGithubOrganizationsMap: [ + {} as OrganizationsMap, + { + setSelectedGithubOrganizations: (state, option) => ({ + ...state, + ...{ [option]: !state[option] }, + }), + resetSourceState: () => ({}), + }, + ], + preContentSourceId: [ + '', + { + setPreContentSourceId: (_, preContentSourceId) => preContentSourceId, + setPreContentSourceConfigData: () => '', + resetSourceState: () => '', + }, + ], + oauthConfigCompleted: [ + false, + { + setPreContentSourceConfigData: () => true, + }, + ], + sourceData: [getSourceData(props.serviceType) || null, {}], }), - saveSourceParams: (search: Search, params: OauthParams, isOrganization: boolean) => ({ - search, - params, - isOrganization, + selectors: ({ selectors }) => ({ + selectedGithubOrganizations: [ + () => [selectors.selectedGithubOrganizationsMap], + (orgsMap) => keys(pickBy(orgsMap)), + ], }), - createContentSource: ( - serviceType: string, - successCallback: () => void, - errorCallback?: () => void - ) => ({ serviceType, successCallback, errorCallback }), - resetSourceState: () => true, - setButtonNotLoading: () => false, - setFirstStep: (addSourceProps) => ({ addSourceProps }), - }, - reducers: { - addSourceProps: [ - {} as AddSourceProps, - { - setAddSourceProps: (_, { addSourceProps }) => addSourceProps, - }, - ], - addSourceCurrentStep: [ - AddSourceSteps.ConfigIntroStep, - { - setAddSourceStep: (_, addSourceCurrentStep) => addSourceCurrentStep, - }, - ], - sourceConfigData: [ - {} as SourceConfigData, - { - setSourceConfigData: (_, sourceConfigData) => sourceConfigData, - }, - ], - sourceConnectData: [ - {} as SourceConnectData, - { - setSourceConnectData: (_, sourceConnectData) => sourceConnectData, - }, - ], - dataLoading: [ - true, - { - setSourceConfigData: () => false, - resetSourceState: () => false, - setPreContentSourceConfigData: () => false, - getSourceConfigData: () => true, - }, - ], - buttonLoading: [ - false, - { - setButtonNotLoading: () => false, - setSourceConnectData: () => false, - setSourceConfigData: () => false, - resetSourceState: () => false, - saveSourceConfig: () => true, - getSourceConnectData: () => true, - createContentSource: () => true, - }, - ], - sectionLoading: [ - true, - { - getPreContentSourceConfigData: () => true, - setPreContentSourceConfigData: () => false, - }, - ], - clientIdValue: [ - '', - { - setClientIdValue: (_, clientIdValue) => clientIdValue, - setSourceConfigData: (_, { configuredFields: { clientId } }) => clientId || '', - resetSourceState: () => '', - }, - ], - clientSecretValue: [ - '', - { - setClientSecretValue: (_, clientSecretValue) => clientSecretValue, - setSourceConfigData: (_, { configuredFields: { clientSecret } }) => clientSecret || '', - resetSourceState: () => '', - }, - ], - baseUrlValue: [ - '', - { - setBaseUrlValue: (_, baseUrlValue) => baseUrlValue, - setSourceConfigData: (_, { configuredFields: { baseUrl } }) => baseUrl || '', - resetSourceState: () => '', - }, - ], - loginValue: [ - '', - { - setSourceLoginValue: (_, loginValue) => loginValue, - resetSourceState: () => '', - }, - ], - passwordValue: [ - '', - { - setSourcePasswordValue: (_, passwordValue) => passwordValue, - resetSourceState: () => '', - }, - ], - subdomainValue: [ - '', - { - setSourceSubdomainValue: (_, subdomainValue) => subdomainValue, - resetSourceState: () => '', - }, - ], - indexPermissionsValue: [ - false, - { - setSourceIndexPermissionsValue: (_, indexPermissionsValue) => indexPermissionsValue, - resetSourceState: () => false, - }, - ], - currentServiceType: [ - '', - { - setPreContentSourceConfigData: (_, { serviceType }) => serviceType, - resetSourceState: () => '', - }, - ], - githubOrganizations: [ - [], - { - setPreContentSourceConfigData: (_, { githubOrganizations }) => githubOrganizations, - resetSourceState: () => [], + listeners: ({ actions, values, props }) => ({ + getSourceConfigData: async () => { + const { serviceType } = props; + // TODO: Once multi-config support for connectors is added, this request url will need to include an ID + const route = `/internal/workplace_search/org/settings/connectors/${serviceType}`; + + try { + const response = await HttpLogic.values.http.get(route); + actions.setSourceConfigData(response); + actions.setFirstStep(); + } catch (e) { + flashAPIErrors(e); + } }, - ], - selectedGithubOrganizationsMap: [ - {} as OrganizationsMap, - { - setSelectedGithubOrganizations: (state, option) => ({ - ...state, - ...{ [option]: !state[option] }, - }), - resetSourceState: () => ({}), + getSourceConnectData: async ({ successCallback }) => { + const { serviceType } = props; + clearFlashMessages(); + const { isOrganization } = AppLogic.values; + const { subdomainValue: subdomain, indexPermissionsValue: indexPermissions } = values; + + const route = isOrganization + ? `/internal/workplace_search/org/sources/${serviceType}/prepare` + : `/internal/workplace_search/account/sources/${serviceType}/prepare`; + + const indexPermissionsQuery = isOrganization + ? { index_permissions: indexPermissions } + : undefined; + + const query = subdomain + ? { + ...indexPermissionsQuery, + subdomain, + } + : { ...indexPermissionsQuery }; + + try { + const response = await HttpLogic.values.http.get(route, { + query, + }); + actions.setSourceConnectData(response); + successCallback(response.oauthUrl); + } catch (e) { + flashAPIErrors(e); + } finally { + actions.setButtonNotLoading(); + } }, - ], - preContentSourceId: [ - '', - { - setPreContentSourceId: (_, preContentSourceId) => preContentSourceId, - setPreContentSourceConfigData: () => '', - resetSourceState: () => '', + getSourceReConnectData: async ({ sourceId }) => { + const { isOrganization } = AppLogic.values; + const route = isOrganization + ? `/internal/workplace_search/org/sources/${sourceId}/reauth_prepare` + : `/internal/workplace_search/account/sources/${sourceId}/reauth_prepare`; + + try { + const response = await HttpLogic.values.http.get(route); + actions.setSourceConnectData(response); + } catch (e) { + flashAPIErrors(e); + } }, - ], - oauthConfigCompleted: [ - false, - { - setPreContentSourceConfigData: () => true, + getPreContentSourceConfigData: async () => { + const { isOrganization } = AppLogic.values; + const { preContentSourceId } = values; + const route = isOrganization + ? `/internal/workplace_search/org/pre_sources/${preContentSourceId}` + : `/internal/workplace_search/account/pre_sources/${preContentSourceId}`; + + try { + const response = await HttpLogic.values.http.get(route); + actions.setPreContentSourceConfigData(response); + } catch (e) { + flashAPIErrors(e); + } }, - ], - }, - selectors: ({ selectors }) => ({ - selectedGithubOrganizations: [ - () => [selectors.selectedGithubOrganizationsMap], - (orgsMap) => keys(pickBy(orgsMap)), - ], - }), - listeners: ({ actions, values }) => ({ - initializeAddSource: ({ addSourceProps }) => { - const { serviceType } = addSourceProps.sourceData; - actions.setAddSourceProps({ addSourceProps }); - actions.getSourceConfigData(serviceType, addSourceProps); - }, - getSourceConfigData: async ({ serviceType, addSourceProps }) => { - const route = `/internal/workplace_search/org/settings/connectors/${serviceType}`; - - try { - const response = await HttpLogic.values.http.get(route); - actions.setSourceConfigData(response); - if (addSourceProps) { - actions.setFirstStep(addSourceProps); + saveSourceConfig: async ({ isUpdating, successCallback }) => { + clearFlashMessages(); + const { + sourceConfigData: { serviceType }, + baseUrlValue, + clientIdValue, + clientSecretValue, + sourceConfigData, + } = values; + + const { externalConnectorUrl, externalConnectorApiKey } = ExternalConnectorLogic.values; + if ( + serviceType === 'external' && + externalConnectorUrl && + !isValidExternalUrl(externalConnectorUrl) + ) { + ExternalConnectorLogic.actions.setUrlValidation(false); + actions.setButtonNotLoading(); + return; } - } catch (e) { - flashAPIErrors(e); - } - }, - getSourceConnectData: async ({ serviceType, successCallback }) => { - clearFlashMessages(); - const { isOrganization } = AppLogic.values; - const { subdomainValue: subdomain, indexPermissionsValue: indexPermissions } = values; - - const route = isOrganization - ? `/internal/workplace_search/org/sources/${serviceType}/prepare` - : `/internal/workplace_search/account/sources/${serviceType}/prepare`; - - const indexPermissionsQuery = isOrganization - ? { index_permissions: indexPermissions } - : undefined; - - const query = subdomain - ? { - ...indexPermissionsQuery, - subdomain, + + const route = isUpdating + ? `/internal/workplace_search/org/settings/connectors/${serviceType}` + : '/internal/workplace_search/org/settings/connectors'; + + const http = isUpdating ? HttpLogic.values.http.put : HttpLogic.values.http.post; + + const params = { + base_url: baseUrlValue || undefined, + client_id: clientIdValue || undefined, + client_secret: clientSecretValue || undefined, + service_type: serviceType, + private_key: sourceConfigData.configuredFields?.privateKey, + public_key: sourceConfigData.configuredFields?.publicKey, + consumer_key: sourceConfigData.configuredFields?.consumerKey, + external_connector_url: (serviceType === 'external' && externalConnectorUrl) || undefined, + external_connector_api_key: + (serviceType === 'external' && externalConnectorApiKey) || undefined, + }; + + try { + const response = await http(route, { + body: JSON.stringify(params), + }); + if (successCallback) successCallback(); + if (isUpdating) { + flashSuccessToast( + i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sources.flashMessages.contentSourceConfigUpdated', + { + defaultMessage: 'Successfully updated configuration.', + } + ) + ); } - : { ...indexPermissionsQuery }; - - try { - const response = await HttpLogic.values.http.get(route, { - query, - }); - actions.setSourceConnectData(response); - successCallback(response.oauthUrl); - } catch (e) { - flashAPIErrors(e); - } finally { - actions.setButtonNotLoading(); - } - }, - getSourceReConnectData: async ({ sourceId }) => { - const { isOrganization } = AppLogic.values; - const route = isOrganization - ? `/internal/workplace_search/org/sources/${sourceId}/reauth_prepare` - : `/internal/workplace_search/account/sources/${sourceId}/reauth_prepare`; - - try { - const response = await HttpLogic.values.http.get(route); - actions.setSourceConnectData(response); - } catch (e) { - flashAPIErrors(e); - } - }, - getPreContentSourceConfigData: async () => { - const { isOrganization } = AppLogic.values; - const { preContentSourceId } = values; - const route = isOrganization - ? `/internal/workplace_search/org/pre_sources/${preContentSourceId}` - : `/internal/workplace_search/account/pre_sources/${preContentSourceId}`; - - try { - const response = await HttpLogic.values.http.get(route); - actions.setPreContentSourceConfigData(response); - } catch (e) { - flashAPIErrors(e); - } - }, - saveSourceConfig: async ({ isUpdating, successCallback }) => { - clearFlashMessages(); - const { - sourceConfigData: { serviceType }, - baseUrlValue, - clientIdValue, - clientSecretValue, - sourceConfigData, - } = values; - - const { externalConnectorUrl, externalConnectorApiKey } = ExternalConnectorLogic.values; - if ( - serviceType === 'external' && - externalConnectorUrl && - !isValidExternalUrl(externalConnectorUrl) - ) { - ExternalConnectorLogic.actions.setUrlValidation(false); - actions.setButtonNotLoading(); - return; - } - - const route = isUpdating - ? `/internal/workplace_search/org/settings/connectors/${serviceType}` - : '/internal/workplace_search/org/settings/connectors'; - - const http = isUpdating ? HttpLogic.values.http.put : HttpLogic.values.http.post; - - const params = { - base_url: baseUrlValue || undefined, - client_id: clientIdValue || undefined, - client_secret: clientSecretValue || undefined, - service_type: serviceType, - private_key: sourceConfigData.configuredFields?.privateKey, - public_key: sourceConfigData.configuredFields?.publicKey, - consumer_key: sourceConfigData.configuredFields?.consumerKey, - external_connector_url: (serviceType === 'external' && externalConnectorUrl) || undefined, - external_connector_api_key: - (serviceType === 'external' && externalConnectorApiKey) || undefined, - }; - - try { - const response = await http(route, { - body: JSON.stringify(params), - }); - if (successCallback) successCallback(); - if (isUpdating) { - flashSuccessToast( - i18n.translate( - 'xpack.enterpriseSearch.workplaceSearch.sources.flashMessages.contentSourceConfigUpdated', - { - defaultMessage: 'Successfully updated configuration.', - } - ) - ); + actions.setSourceConfigData(response); + } catch (e) { + flashAPIErrors(e); + } finally { + actions.setButtonNotLoading(); } - actions.setSourceConfigData(response); - } catch (e) { - flashAPIErrors(e); - } finally { - actions.setButtonNotLoading(); - } - }, - saveSourceParams: async ({ search, params, isOrganization }) => { - const { http } = HttpLogic.values; - const { navigateToUrl } = KibanaLogic.values; - const { setAddedSource } = SourcesLogic.actions; - const query = { ...params }; - const route = '/internal/workplace_search/sources/create'; - - /** + }, + saveSourceParams: async ({ search, params, isOrganization }) => { + const { http } = HttpLogic.values; + const { navigateToUrl } = KibanaLogic.values; + const { setAddedSource } = SourcesLogic.actions; + const query = { ...params }; + const route = '/internal/workplace_search/sources/create'; + + /** There is an extreme edge case where the user is trying to connect Github as source from ent-search, after configuring it in Kibana. When this happens, Github redirects the user from ent-search to Kibana with special error properties in the query params. In this case we need to redirect the user to the app home page and display the error message, and not persist the other query params to the server. */ - if (params.error_description) { - navigateToUrl(isOrganization ? '/' : PRIVATE_SOURCES_PATH); - setErrorMessage( - isOrganization - ? params.error_description - : PERSONAL_DASHBOARD_SOURCE_ERROR(params.error_description) - ); - return; - } - - try { - const response = await http.get<{ - serviceName: string; - indexPermissions: boolean; - serviceType: string; - preContentSourceId: string; - hasConfigureStep: boolean; - }>(route, { query }); - const { serviceName, indexPermissions, serviceType, preContentSourceId, hasConfigureStep } = - response; - - // GitHub requires an intermediate configuration step, where we collect the repos to index. - if (hasConfigureStep && !values.oauthConfigCompleted) { - actions.setPreContentSourceId(preContentSourceId); - navigateToUrl( - getSourcesPath(`${getAddPath('github')}/configure${search}`, isOrganization) + if (params.error_description) { + navigateToUrl(isOrganization ? '/' : PRIVATE_SOURCES_PATH); + setErrorMessage( + isOrganization + ? params.error_description + : PERSONAL_DASHBOARD_SOURCE_ERROR(params.error_description) ); - } else { - setAddedSource(serviceName, indexPermissions, serviceType); + return; + } + + try { + const response = await http.get<{ + serviceName: string; + indexPermissions: boolean; + serviceType: string; + preContentSourceId: string; + hasConfigureStep: boolean; + }>(route, { query }); + const { + serviceName, + indexPermissions, + serviceType, + preContentSourceId, + hasConfigureStep, + } = response; + + // GitHub requires an intermediate configuration step, where we collect the repos to index. + if (hasConfigureStep && !values.oauthConfigCompleted) { + actions.setPreContentSourceId(preContentSourceId); + navigateToUrl( + getSourcesPath(`${getAddPath('github')}/configure${search}`, isOrganization) + ); + } else { + setAddedSource(serviceName, indexPermissions, serviceType); + navigateToUrl(getSourcesPath(SOURCES_PATH, isOrganization)); + } + } catch (e) { navigateToUrl(getSourcesPath(SOURCES_PATH, isOrganization)); + flashAPIErrors(e); } - } catch (e) { - navigateToUrl(getSourcesPath(SOURCES_PATH, isOrganization)); - flashAPIErrors(e); - } - }, - setFirstStep: ({ addSourceProps }) => { - const firstStep = getFirstStep( - addSourceProps, - values.sourceConfigData, - SourcesLogic.values.externalConfigured - ); - actions.setAddSourceStep(firstStep); - }, - createContentSource: async ({ serviceType, successCallback, errorCallback }) => { - clearFlashMessages(); - const { isOrganization } = AppLogic.values; - const route = isOrganization - ? '/internal/workplace_search/org/create_source' - : '/internal/workplace_search/account/create_source'; - - const { - selectedGithubOrganizations: githubOrganizations, - loginValue, - passwordValue, - indexPermissionsValue, - } = values; - - const params = { - service_type: serviceType, - login: loginValue || undefined, - password: passwordValue || undefined, - organizations: githubOrganizations.length > 0 ? githubOrganizations : undefined, - index_permissions: indexPermissionsValue || undefined, - } as { - [key: string]: string | string[] | undefined; - }; - - // Remove undefined values from params - Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]); - - try { - await HttpLogic.values.http.post(route, { - body: JSON.stringify({ ...params }), - }); - successCallback(); - } catch (e) { - flashAPIErrors(e); - if (errorCallback) errorCallback(); - } finally { - actions.setButtonNotLoading(); - } - }, - }), -}); - -const getFirstStep = ( - props: AddSourceProps, - sourceConfigData: SourceConfigData, - externalConfigured: boolean -): AddSourceSteps => { + }, + setFirstStep: () => { + const firstStep = getFirstStep(values.sourceConfigData, props.initialStep); + actions.setAddSourceStep(firstStep); + }, + createContentSource: async ({ successCallback, errorCallback }) => { + const { serviceType } = props; + clearFlashMessages(); + const { isOrganization } = AppLogic.values; + const route = isOrganization + ? '/internal/workplace_search/org/create_source' + : '/internal/workplace_search/account/create_source'; + + const { + selectedGithubOrganizations: githubOrganizations, + loginValue, + passwordValue, + indexPermissionsValue, + } = values; + + const params = { + service_type: serviceType, + login: loginValue || undefined, + password: passwordValue || undefined, + organizations: githubOrganizations.length > 0 ? githubOrganizations : undefined, + index_permissions: indexPermissionsValue || undefined, + } as { + [key: string]: string | string[] | undefined; + }; + + // Remove undefined values from params + Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]); + + try { + await HttpLogic.values.http.post(route, { + body: JSON.stringify({ ...params }), + }); + successCallback(); + } catch (e) { + flashAPIErrors(e); + if (errorCallback) errorCallback(); + } finally { + actions.setButtonNotLoading(); + } + }, + }), + } +); + +const getFirstStep = (sourceConfigData: SourceConfigData, initialStep?: string): AddSourceSteps => { const { - connect, - configure, - reAuthenticate, - sourceData: { serviceType, externalConnectorAvailable }, - } = props; - // We can land on this page from a choice page for multiple types of connectors - // If that's the case we want to skip the intro and configuration, if the external & internal connector have already been configured - const { configuredFields, configured } = sourceConfigData; - if (externalConnectorAvailable && configured && externalConfigured) + serviceType, + configured, + configuredFields: { clientId, clientSecret }, + } = sourceConfigData; + if (initialStep === 'connect') return AddSourceSteps.ConnectInstanceStep; + if (initialStep === 'configure') return AddSourceSteps.ConfigureOauthStep; + if (initialStep === 'reauthenticate') return AddSourceSteps.ReauthenticateStep; + if (serviceType !== 'external' && configured) return AddSourceSteps.ConnectInstanceStep; + + // TODO remove this once external/BYO connectors track `configured` properly + if (serviceType === 'external' && clientId && clientSecret) return AddSourceSteps.ConnectInstanceStep; - if (externalConnectorAvailable && !configured && externalConfigured) - return AddSourceSteps.SaveConfigStep; - if (serviceType === 'external') { - // external connectors can be partially configured, so we need to check which fields are filled - if (configuredFields?.clientId && configuredFields?.clientSecret) { - return AddSourceSteps.ConnectInstanceStep; - } - // Unconfigured external connectors have already shown the intro step before the choice page, so we don't want to show it again - return AddSourceSteps.SaveConfigStep; - } - if (connect) return AddSourceSteps.ConnectInstanceStep; - if (configure) return AddSourceSteps.ConfigureOauthStep; - if (reAuthenticate) return AddSourceSteps.ReauthenticateStep; - return AddSourceSteps.ConfigIntroStep; + + return AddSourceSteps.SaveConfigStep; }; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx index 06815ab3330f0d6..a44b5f54852c930 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.test.tsx @@ -26,7 +26,7 @@ describe('AvailableSourcesList', () => { const wrapper = shallow(); expect(wrapper.find(EuiTitle)).toHaveLength(1); - expect(wrapper.find('[data-test-subj="AvailableSourceListItem"]')).toHaveLength(24); + expect(wrapper.find('[data-test-subj="AvailableSourceListItem"]')).toHaveLength(25); expect(wrapper.find('[data-test-subj="CustomAPISourceLink"]')).toHaveLength(1); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx index 7dc9ad9ca0f60ef..9a2787d77907080 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/available_sources_list.tsx @@ -18,6 +18,7 @@ import { EuiTitle, EuiText, EuiToolTip, + EuiButtonEmpty, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -43,8 +44,13 @@ interface AvailableSourcesListProps { export const AvailableSourcesList: React.FC = ({ sources }) => { const { hasPlatinumLicense } = useValues(LicensingLogic); - const getSourceCard = ({ name, serviceType, accountContextOnly }: SourceDataItem) => { - const addPath = getAddPath(serviceType); + const getSourceCard = ({ + accountContextOnly, + baseServiceType, + name, + serviceType, + }: SourceDataItem) => { + const addPath = getAddPath(serviceType, baseServiceType); const disabled = !hasPlatinumLicense && accountContextOnly; const connectButton = () => { @@ -61,15 +67,30 @@ export const AvailableSourcesList: React.FC = ({ sour } )} > - - Connect - + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.availableSourceList.connectButtonLabel', + { + defaultMessage: 'Connect', + } + )} + ); } else { return ( - - Connect + + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.contentSource.availableSourceList.connectButtonLabel', + { + defaultMessage: 'Connect', + } + )} ); } @@ -79,7 +100,7 @@ export const AvailableSourcesList: React.FC = ({ sour <> - + {name} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx index 94821c0561cf4c7..0ed33a01d606f3f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.test.tsx @@ -5,20 +5,19 @@ * 2.0. */ -import { mockKibanaValues, setMockValues } from '../../../../../__mocks__/kea_logic'; +import { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; import React from 'react'; import { mount } from 'enzyme'; -import { EuiButton } from '@elastic/eui'; +import { EuiButtonTo } from '../../../../../shared/react_router_helpers'; import { staticSourceData } from '../../source_data'; import { ConfigurationChoice } from './configuration_choice'; describe('ConfigurationChoice', () => { - const { navigateToUrl } = mockKibanaValues; const props = { sourceData: staticSourceData[0], }; @@ -28,31 +27,23 @@ describe('ConfigurationChoice', () => { categories: [], }, }; + const mockActions = { + initializeSources: jest.fn(), + resetSourcesState: jest.fn(), + }; beforeEach(() => { - setMockValues(mockValues); jest.clearAllMocks(); + setMockValues(mockValues); + setMockActions(mockActions); }); it('renders internal connector if available', () => { const wrapper = mount(); - expect(wrapper.find('EuiCard')).toHaveLength(1); - expect(wrapper.find(EuiButton)).toHaveLength(1); - }); - it('should navigate to internal connector on internal connector click', () => { - const wrapper = mount(); - const button = wrapper.find(EuiButton); - button.simulate('click'); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/internal/'); - }); - it('should call prop function when provided on internal connector click', () => { - const advanceSpy = jest.fn(); - const wrapper = mount(); - const button = wrapper.find(EuiButton); - button.simulate('click'); - expect(navigateToUrl).not.toHaveBeenCalled(); - expect(advanceSpy).toHaveBeenCalled(); + const internalConnectorCard = wrapper.find('[data-test-subj="InternalConnectorCard"]'); + expect(internalConnectorCard).toHaveLength(1); + expect(internalConnectorCard.find(EuiButtonTo).prop('to')).toEqual('/sources/add/box/'); }); it('renders external connector if available', () => { @@ -62,32 +53,36 @@ describe('ConfigurationChoice', () => { ...props, sourceData: { ...props.sourceData, - internalConnectorAvailable: false, - externalConnectorAvailable: true, + serviceType: 'share_point', }, }} /> ); - expect(wrapper.find('EuiCard')).toHaveLength(1); - expect(wrapper.find(EuiButton)).toHaveLength(1); + const externalConnectorCard = wrapper.find('[data-test-subj="ExternalConnectorCard"]'); + expect(externalConnectorCard).toHaveLength(1); + expect(externalConnectorCard.find(EuiButtonTo).prop('to')).toEqual( + '/sources/add/share_point/external/connector_registration' + ); }); - it('should navigate to external connector on external connector click', () => { + + it('renders disabled message if external connector is available but user has already configured', () => { + setMockValues({ ...mockValues, externalConfigured: true }); + const wrapper = mount( ); - const button = wrapper.find(EuiButton); - button.simulate('click'); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/external/'); + + const externalConnectorCard = wrapper.find('[data-test-subj="ExternalConnectorCard"]'); + expect(externalConnectorCard.prop('disabledMessage')).toBeDefined(); }); it('renders custom connector if available', () => { @@ -97,33 +92,16 @@ describe('ConfigurationChoice', () => { ...props, sourceData: { ...props.sourceData, - internalConnectorAvailable: false, - externalConnectorAvailable: false, - customConnectorAvailable: true, + serviceType: 'share_point_server', }, }} /> ); - expect(wrapper.find('EuiCard')).toHaveLength(1); - expect(wrapper.find(EuiButton)).toHaveLength(1); - }); - it('should navigate to custom connector on custom connector click', () => { - const wrapper = mount( - + const customConnectorCard = wrapper.find('[data-test-subj="CustomConnectorCard"]'); + expect(customConnectorCard).toHaveLength(1); + expect(customConnectorCard.find(EuiButtonTo).prop('to')).toEqual( + '/sources/add/share_point_server/custom' ); - const button = wrapper.find(EuiButton); - button.simulate('click'); - expect(navigateToUrl).toHaveBeenCalledWith('/sources/add/box/custom/'); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx index 8d8311d2a0a6f14..7d5721d8547d2a9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_choice.tsx @@ -5,92 +5,85 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; -import { useValues } from 'kea'; +import { useActions, useValues } from 'kea'; + +import { EuiCard, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import { EuiButton, EuiCard, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { KibanaLogic } from '../../../../../shared/kibana'; +import { EuiButtonTo } from '../../../../../shared/react_router_helpers'; import { AppLogic } from '../../../../app_logic'; import { getAddPath, getSourcesPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; -import { AddSourceHeader } from './add_source_header'; -import { AddSourceLogic } from './add_source_logic'; +import { hasCustomConnectorOption, hasExternalConnectorOption } from '../../source_data'; -interface ConfigurationChoiceProps { - sourceData: SourceDataItem; - goToInternalStep?: () => void; -} +import { SourcesLogic } from '../../sources_logic'; + +import { AddSourceHeader } from './add_source_header'; interface CardProps { title: string; description: string; buttonText: string; - onClick: () => void; + to: string; badgeLabel?: string; + disabledMessage?: string; +} + +const ConnectorCard: React.FC = ({ + title, + description, + buttonText, + to, + badgeLabel, + disabledMessage, +}: CardProps) => ( + + + {buttonText} + + } + /> + +); + +interface ConfigurationChoiceProps { + sourceData: SourceDataItem; } export const ConfigurationChoice: React.FC = ({ - sourceData: { - name, - serviceType, - externalConnectorAvailable, - internalConnectorAvailable, - customConnectorAvailable, - }, - goToInternalStep, + sourceData: { name, categories = [], serviceType }, }) => { + const externalConnectorAvailable = hasExternalConnectorOption(serviceType); + const customConnectorAvailable = hasCustomConnectorOption(serviceType); + const { isOrganization } = useValues(AppLogic); - const { sourceConfigData } = useValues(AddSourceLogic); - const { categories } = sourceConfigData; - const goToInternal = goToInternalStep - ? goToInternalStep - : () => - KibanaLogic.values.navigateToUrl( - `${getSourcesPath( - `${getSourcesPath(getAddPath(serviceType), isOrganization)}/internal`, - isOrganization - )}/` - ); - const goToExternal = () => - KibanaLogic.values.navigateToUrl( - `${getSourcesPath( - `${getSourcesPath(getAddPath(serviceType), isOrganization)}/external`, - isOrganization - )}/` - ); - const goToCustom = () => - KibanaLogic.values.navigateToUrl( - `${getSourcesPath( - `${getSourcesPath(getAddPath(serviceType), isOrganization)}/custom`, - isOrganization - )}/` - ); - - const ConnectorCard: React.FC = ({ - title, - description, - buttonText, - onClick, - badgeLabel, - }: CardProps) => ( - - - {buttonText} - - } - /> - - ); + + const { initializeSources, resetSourcesState } = useActions(SourcesLogic); + + const { externalConfigured } = useValues(SourcesLogic); + + useEffect(() => { + initializeSources(); + return resetSourcesState; + }, []); + + const internalTo = `${getSourcesPath(getAddPath(serviceType), isOrganization)}/`; + const externalTo = `${getSourcesPath( + getAddPath('external', serviceType), + isOrganization + )}/connector_registration`; + const customTo = `${getSourcesPath(getAddPath('custom', serviceType), isOrganization)}`; const internalConnectorProps: CardProps = { title: i18n.translate( @@ -118,7 +111,7 @@ export const ConfigurationChoice: React.FC = ({ defaultMessage: 'Recommended', } ), - onClick: goToInternal, + to: internalTo, }; const externalConnectorProps: CardProps = { @@ -141,7 +134,7 @@ export const ConfigurationChoice: React.FC = ({ defaultMessage: 'Instructions', } ), - onClick: goToExternal, + to: externalTo, badgeLabel: i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configExternalChoice.external.betaLabel', { @@ -169,7 +162,7 @@ export const ConfigurationChoice: React.FC = ({ defaultMessage: 'Instructions', } ), - onClick: goToCustom, + to: customTo, }; return ( @@ -177,9 +170,26 @@ export const ConfigurationChoice: React.FC = ({ - {internalConnectorAvailable && } - {externalConnectorAvailable && } - {customConnectorAvailable && } + + {externalConnectorAvailable && ( + + )} + {customConnectorAvailable && ( + + )} ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.test.tsx index b3ce53a0321dc27..0f1beff70735cd8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.test.tsx @@ -14,11 +14,10 @@ import { EuiText, EuiTitle } from '@elastic/eui'; import { ConfigurationIntro } from './configuration_intro'; describe('ConfigurationIntro', () => { - const advanceStep = jest.fn(); const props = { header:

    Header

    , name: 'foo', - advanceStep, + advanceStepTo: '', }; it('renderscontext', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx index 5c52537d4a73841..e5da9f6e003167d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configuration_intro.tsx @@ -9,7 +9,6 @@ import React from 'react'; import { EuiBadge, - EuiButton, EuiFlexGroup, EuiFlexItem, EuiFormRow, @@ -18,9 +17,12 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; + import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiButtonTo } from '../../../../../shared/react_router_helpers'; + import connectionIllustration from '../../../../assets/connection_illustration.svg'; import { @@ -37,12 +39,12 @@ import { interface ConfigurationIntroProps { header: React.ReactNode; name: string; - advanceStep(): void; + advanceStepTo: string; } export const ConfigurationIntro: React.FC = ({ name, - advanceStep, + advanceStepTo, header, }) => ( <> @@ -144,11 +146,11 @@ export const ConfigurationIntro: React.FC = ({ - {i18n.translate( 'xpack.enterpriseSearch.workplaceSearch.contentSource.configIntro.configure.button', @@ -157,7 +159,7 @@ export const ConfigurationIntro: React.FC = ({ values: { name }, } )} - + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.test.tsx index 332456cae99ad09..c776723377f4414 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.test.tsx @@ -22,7 +22,7 @@ describe('ConfigureOauth', () => { const onFormCreated = jest.fn(); const getPreContentSourceConfigData = jest.fn(); const setSelectedGithubOrganizations = jest.fn(); - const createContentSource = jest.fn((_, formSubmitSuccess, handleFormSubmitError) => { + const createContentSource = jest.fn((formSubmitSuccess, handleFormSubmitError) => { formSubmitSuccess(); handleFormSubmitError(); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.tsx index ce5a92a19e3875d..af50e8267da2f3b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configure_oauth.tsx @@ -35,12 +35,8 @@ export const ConfigureOauth: React.FC = ({ name, onFormCrea const { getPreContentSourceConfigData, setSelectedGithubOrganizations, createContentSource } = useActions(AddSourceLogic); - const { - currentServiceType, - githubOrganizations, - selectedGithubOrganizationsMap, - sectionLoading, - } = useValues(AddSourceLogic); + const { githubOrganizations, selectedGithubOrganizationsMap, sectionLoading } = + useValues(AddSourceLogic); const checkboxOptions = githubOrganizations.map((item) => ({ id: item, label: item })); @@ -54,7 +50,7 @@ export const ConfigureOauth: React.FC = ({ name, onFormCrea const handleFormSubmit = (e: FormEvent) => { setFormLoading(true); e.preventDefault(); - createContentSource(currentServiceType, formSubmitSuccess, handleFormSubmitError); + createContentSource(formSubmitSuccess, handleFormSubmitError); }; const configfieldsForm = ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx index 5b23368289f1ad7..998a4c1d53b8abb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.test.tsx @@ -11,6 +11,8 @@ import React from 'react'; import { shallow } from 'enzyme'; +import { EuiButtonEmpty } from '@elastic/eui'; + import { EuiButtonEmptyTo } from '../../../../../shared/react_router_helpers'; import { ConfiguredSourcesList } from './configured_sources_list'; @@ -24,47 +26,38 @@ describe('ConfiguredSourcesList', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find('[data-test-subj="UnConnectedTooltip"]')).toHaveLength(20); + expect(wrapper.find('[data-test-subj="UnConnectedTooltip"]')).toHaveLength(21); expect(wrapper.find('[data-test-subj="AccountOnlyTooltip"]')).toHaveLength(2); - expect(wrapper.find('[data-test-subj="ConfiguredSourcesListItem"]')).toHaveLength(23); - }); - - it('does show connect button for a connected external source', () => { - const wrapper = shallow( - - ); - expect(wrapper.find(EuiButtonEmptyTo)).toHaveLength(1); + expect(wrapper.find('[data-test-subj="ConfiguredSourcesListItem"]')).toHaveLength(24); }); - it('does show connect button for an unconnected external source', () => { + it('shows connect button for an source with multiple connector options that routes to choice page', () => { const wrapper = shallow( ); const button = wrapper.find(EuiButtonEmptyTo); expect(button).toHaveLength(1); - expect(button.prop('to')).toEqual('/sources/add/external/connect'); + expect(button.prop('to')).toEqual('/sources/add/share_point/choice'); }); - it('connect button for an unconnected source with multiple connector options routes to choice page', () => { + it('shows connect button for a source without multiple connector options that routes to add page', () => { const wrapper = shallow( { ); const button = wrapper.find(EuiButtonEmptyTo); expect(button).toHaveLength(1); - expect(button.prop('to')).toEqual('/sources/add/share_point/'); + expect(button.prop('to')).toEqual('/sources/add/slack/'); }); - it('connect button for a source with multiple connector options routes to connect page for private sources', () => { + it('disabled when in organization mode and connector is account context only', () => { const wrapper = shallow( ); - const button = wrapper.find(EuiButtonEmptyTo); + const button = wrapper.find(EuiButtonEmpty); expect(button).toHaveLength(1); - expect(button.prop('to')).toEqual('/p/sources/add/share_point/connect'); + expect(button.prop('isDisabled')).toBe(true); }); it('handles empty state', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx index bbec096ae07d8e8..820df302725b7dc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/configured_sources_list.tsx @@ -27,7 +27,8 @@ import { EuiButtonEmptyTo } from '../../../../../shared/react_router_helpers'; import { SourceIcon } from '../../../../components/shared/source_icon'; import { getAddPath, getSourcesPath } from '../../../../routes'; import { SourceDataItem } from '../../../../types'; -import { hasMultipleConnectorOptions } from '../../../../utils'; + +import { hasMultipleConnectorOptions } from '../../source_data'; import { CONFIGURED_SOURCES_LIST_UNCONNECTED_TOOLTIP, @@ -72,7 +73,8 @@ export const ConfiguredSourcesList: React.FC = ({ const visibleSources = ( {sources.map((sourceData, i) => { - const { connected, accountContextOnly, name, serviceType, isBeta } = sourceData; + const { connected, accountContextOnly, name, serviceType, isBeta, baseServiceType } = + sourceData; return ( = ({ responsive={false} > - + @@ -128,7 +134,7 @@ export const ConfiguredSourcesList: React.FC = ({ {!connected diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx index 3e850277c0b720f..992bb561796fe0e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.test.tsx @@ -33,10 +33,10 @@ describe('ConnectInstance', () => { const setSourcePasswordValue = jest.fn(); const setSourceSubdomainValue = jest.fn(); const setSourceIndexPermissionsValue = jest.fn(); - const getSourceConnectData = jest.fn((_, redirectOauth) => { + const getSourceConnectData = jest.fn((redirectOauth) => { redirectOauth(); }); - const createContentSource = jest.fn((_, redirectFormCreated) => { + const createContentSource = jest.fn((redirectFormCreated) => { redirectFormCreated(); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx index 352addd8176d842..0a4c1a9692e636c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/connect_instance.tsx @@ -46,7 +46,6 @@ export const ConnectInstance: React.FC = ({ features, objTypes, name, - serviceType, needsPermissions, onFormCreated, header, @@ -74,8 +73,8 @@ export const ConnectInstance: React.FC = ({ const redirectOauth = (oauthUrl: string) => window.location.replace(oauthUrl); const redirectFormCreated = () => onFormCreated(name); - const onOauthFormSubmit = () => getSourceConnectData(serviceType, redirectOauth); - const onCredentialsFormSubmit = () => createContentSource(serviceType, redirectFormCreated); + const onOauthFormSubmit = () => getSourceConnectData(redirectOauth); + const onCredentialsFormSubmit = () => createContentSource(redirectFormCreated); const handleFormSubmit = (e: FormEvent) => { e.preventDefault(); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/source_features.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/source_features.tsx index edfb2897fce1529..7a80c9d6980b5a6 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/source_features.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/add_source/source_features.tsx @@ -39,7 +39,7 @@ import { SOURCE_FEATURES_GLOBAL_ACCESS_PERMISSIONS_FEATURE_DESCRIPTION, } from './constants'; -interface ConnectInstanceProps { +interface SourceFeatureProps { features?: Features; objTypes?: string[]; name: string; @@ -47,7 +47,7 @@ interface ConnectInstanceProps { type IncludedFeatureIds = Exclude; -export const SourceFeatures: React.FC = ({ features, objTypes, name }) => { +export const SourceFeatures: React.FC = ({ features, objTypes, name }) => { const { hasPlatinumLicense } = useValues(LicensingLogic); const { isOrganization } = useValues(AppLogic); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.test.tsx index afacfd0ccbbf96d..017a9eb5b5dd0d2 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.test.tsx @@ -24,14 +24,6 @@ const customSource = { name: 'name', }; -const preconfiguredSourceData = { - ...staticCustomSourceData, - serviceType: 'sharepoint-server', - configuration: { - ...staticCustomSourceData.configuration, - githubRepository: 'elastic/sharepoint-server-connector', - }, -}; const mockValues = { sourceData: staticCustomSourceData, }; @@ -44,9 +36,7 @@ describe('CustomSourceDeployment', () => { jest.clearAllMocks(); setMockValues(mockValues); - wrapper = shallow( - - ); + wrapper = shallow(); }); it('contains a source identifier', () => { @@ -69,7 +59,7 @@ describe('CustomSourceDeployment', () => { }); wrapper = shallow( - + ); }); @@ -86,9 +76,7 @@ describe('CustomSourceDeployment', () => { jest.clearAllMocks(); setMockValues(mockValues); - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper.find(EuiPanel).prop('paddingSize')).toEqual('m'); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.tsx index 7d34783e998a78f..8910a8acd0c5a08 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/custom_source_deployment.tsx @@ -14,17 +14,30 @@ import { EuiLinkTo } from '../../../../shared/react_router_helpers'; import { API_KEY_LABEL } from '../../../constants'; import { API_KEYS_PATH } from '../../../routes'; -import { ContentSource, CustomSource, SourceDataItem } from '../../../types'; +import { ContentSource, CustomSource } from '../../../types'; + +import { getSourceData } from '../source_data'; import { SourceIdentifier } from './source_identifier'; interface Props { source: ContentSource | CustomSource; - sourceData: SourceDataItem; + baseServiceType?: string; small?: boolean; } -export const CustomSourceDeployment: React.FC = ({ source, sourceData, small = false }) => { +export const CustomSourceDeployment: React.FC = ({ + source, + baseServiceType, + small = false, +}) => { const { name, id } = source; + + const sourceData = getSourceData('custom', baseServiceType); + + if (!sourceData) { + return null; + } + const { configuration: { documentationUrl, githubRepository }, } = sourceData; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx index 9af4eae693d7c84..ae6e516ef7d4af7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.test.tsx @@ -16,8 +16,6 @@ import { EuiCallOut, EuiConfirmModal, EuiEmptyPrompt, EuiTable } from '@elastic/ import { ComponentLoader } from '../../../components/shared/component_loader'; -import * as SourceData from '../source_data'; - import { CustomSourceDeployment } from './custom_source_deployment'; import { Overview } from './overview'; @@ -144,33 +142,6 @@ describe('Overview', () => { expect(initializeSourceSynchronization).toHaveBeenCalled(); }); - it('uses a base service type if one is provided', () => { - jest.spyOn(SourceData, 'getSourceData'); - setMockValues({ - ...mockValues, - contentSource: { - ...fullContentSources[0], - baseServiceType: 'share_point_server', - }, - }); - - shallow(); - - expect(SourceData.getSourceData).toHaveBeenCalledWith('share_point_server'); - }); - - it('defaults to the regular service tye', () => { - jest.spyOn(SourceData, 'getSourceData'); - setMockValues({ - ...mockValues, - contentSource: fullContentSources[0], - }); - - shallow(); - - expect(SourceData.getSourceData).toHaveBeenCalledWith('custom'); - }); - describe('custom sources', () => { it('includes deployment instructions', () => { setMockValues({ diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx index 698dc7a60eea45e..ac31ee8314fc8c7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/overview.tsx @@ -81,7 +81,6 @@ import { SOURCE_SYNC_CONFIRM_TITLE, SOURCE_SYNC_CONFIRM_MESSAGE, } from '../constants'; -import { getSourceData } from '../source_data'; import { SourceLogic } from '../source_logic'; import { CustomSourceDeployment } from './custom_source_deployment'; @@ -106,12 +105,10 @@ export const Overview: React.FC = () => { isFederatedSource, isIndexedSource, name, + serviceType, + baseServiceType, } = contentSource; - const serviceType = contentSource.baseServiceType || contentSource.serviceType; - - const sourceData = getSourceData(serviceType); - const [isSyncing, setIsSyncing] = useState(false); const [isModalVisible, setIsModalVisible] = useState(false); const closeModal = () => setIsModalVisible(false); @@ -431,7 +428,7 @@ export const Overview: React.FC = () => {
    - + ); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx index d660b4499e21052..f872648fc101d00 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/components/source_settings.tsx @@ -88,7 +88,7 @@ export const SourceSettings: React.FC = () => { const { isOrganization } = useValues(AppLogic); useEffect(() => { - getSourceConfigData(serviceType); + getSourceConfigData(); }, []); const isGithubApp = diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx index 282de2590df7f8d..0088e80066a02e8 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx @@ -17,9 +17,10 @@ import { } from '../../constants'; import { FeatureIds, SourceDataItem } from '../../types'; -export const staticExternalSourceData: SourceDataItem = { +// TODO remove Sharepoint-specific content after BYO connector support +export const staticGenericExternalSourceData: SourceDataItem = { name: SOURCE_NAMES.SHAREPOINT, - iconName: SOURCE_NAMES.SHAREPOINT, + categories: [], serviceType: 'external', configuration: { isPublicKey: false, @@ -40,16 +41,12 @@ export const staticExternalSourceData: SourceDataItem = { platinumPrivateContext: [FeatureIds.Private, FeatureIds.SyncFrequency, FeatureIds.SyncedItems], }, accountContextOnly: false, - internalConnectorAvailable: true, - externalConnectorAvailable: false, - customConnectorAvailable: false, isBeta: true, }; export const staticSourceData: SourceDataItem[] = [ { name: SOURCE_NAMES.BOX, - iconName: SOURCE_NAMES.BOX, serviceType: 'box', configuration: { isPublicKey: false, @@ -74,11 +71,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.CONFLUENCE, - iconName: SOURCE_NAMES.CONFLUENCE, serviceType: 'confluence_cloud', configuration: { isPublicKey: false, @@ -108,11 +103,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.CONFLUENCE_SERVER, - iconName: SOURCE_NAMES.CONFLUENCE_SERVER, serviceType: 'confluence_server', configuration: { isPublicKey: true, @@ -140,11 +133,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.DROPBOX, - iconName: SOURCE_NAMES.DROPBOX, serviceType: 'dropbox', configuration: { isPublicKey: false, @@ -169,11 +160,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GITHUB, - iconName: SOURCE_NAMES.GITHUB, serviceType: 'github', configuration: { isPublicKey: false, @@ -205,11 +194,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GITHUB_ENTERPRISE, - iconName: SOURCE_NAMES.GITHUB_ENTERPRISE, serviceType: 'github_enterprise_server', configuration: { isPublicKey: false, @@ -247,11 +234,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GMAIL, - iconName: SOURCE_NAMES.GMAIL, serviceType: 'gmail', configuration: { isPublicKey: false, @@ -265,11 +250,9 @@ export const staticSourceData: SourceDataItem[] = [ platinumPrivateContext: [FeatureIds.Remote, FeatureIds.Private, FeatureIds.SearchableContent], }, accountContextOnly: true, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.GOOGLE_DRIVE, - iconName: SOURCE_NAMES.GOOGLE_DRIVE, serviceType: 'google_drive', configuration: { isPublicKey: false, @@ -298,11 +281,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.JIRA, - iconName: SOURCE_NAMES.JIRA, serviceType: 'jira_cloud', configuration: { isPublicKey: false, @@ -334,11 +315,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.JIRA_SERVER, - iconName: SOURCE_NAMES.JIRA_SERVER, serviceType: 'jira_server', configuration: { isPublicKey: true, @@ -369,13 +348,12 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.NETWORK_DRVE, - iconName: SOURCE_NAMES.NETWORK_DRVE, categories: [SOURCE_CATEGORIES.STORAGE], - serviceType: 'network_drive', // this doesn't exist on the BE + serviceType: 'custom', + baseServiceType: 'network_drive', configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -385,12 +363,9 @@ export const staticSourceData: SourceDataItem[] = [ githubRepository: 'elastic/enterprise-search-network-drive-connector', }, accountContextOnly: false, - internalConnectorAvailable: false, - customConnectorAvailable: true, }, { name: SOURCE_NAMES.ONEDRIVE, - iconName: SOURCE_NAMES.ONEDRIVE, serviceType: 'one_drive', configuration: { isPublicKey: false, @@ -415,17 +390,16 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.OUTLOOK, - iconName: SOURCE_NAMES.OUTLOOK, categories: [ SOURCE_CATEGORIES.COMMUNICATIONS, SOURCE_CATEGORIES.PRODUCTIVITY, SOURCE_CATEGORIES.MICROSOFT, ], - serviceType: 'outlook', // this doesn't exist on the BE + serviceType: 'custom', + baseServiceType: 'outlook', configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -435,12 +409,9 @@ export const staticSourceData: SourceDataItem[] = [ githubRepository: 'elastic/enterprise-search-outlook-connector', }, accountContextOnly: false, - internalConnectorAvailable: false, - customConnectorAvailable: true, }, { name: SOURCE_NAMES.SALESFORCE, - iconName: SOURCE_NAMES.SALESFORCE, serviceType: 'salesforce', configuration: { isPublicKey: false, @@ -472,11 +443,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.SALESFORCE_SANDBOX, - iconName: SOURCE_NAMES.SALESFORCE_SANDBOX, serviceType: 'salesforce_sandbox', configuration: { isPublicKey: false, @@ -508,11 +477,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.SERVICENOW, - iconName: SOURCE_NAMES.SERVICENOW, serviceType: 'service_now', configuration: { isPublicKey: false, @@ -541,11 +508,9 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.SHAREPOINT, - iconName: SOURCE_NAMES.SHAREPOINT, serviceType: 'share_point', configuration: { isPublicKey: false, @@ -570,13 +535,39 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, - externalConnectorAvailable: true, }, - staticExternalSourceData, + { + name: SOURCE_NAMES.SHAREPOINT, + categories: [], + serviceType: 'external', + baseServiceType: 'share_point', + configuration: { + isPublicKey: false, + hasOauthRedirect: true, + needsBaseUrl: false, + documentationUrl: docLinks.workplaceSearchExternalSharePointOnline, + applicationPortalUrl: 'https://portal.azure.com/', + }, + objTypes: [SOURCE_OBJ_TYPES.ALL_STORED_FILES], + features: { + basicOrgContext: [ + FeatureIds.SyncFrequency, + FeatureIds.SyncedItems, + FeatureIds.GlobalAccessPermissions, + ], + basicOrgContextExcludedFeatures: [FeatureIds.DocumentLevelPermissions], + platinumOrgContext: [FeatureIds.SyncFrequency, FeatureIds.SyncedItems], + platinumPrivateContext: [ + FeatureIds.Private, + FeatureIds.SyncFrequency, + FeatureIds.SyncedItems, + ], + }, + accountContextOnly: false, + isBeta: true, + }, { name: SOURCE_NAMES.SHAREPOINT_SERVER, - iconName: SOURCE_NAMES.SHAREPOINT_SERVER, categories: [ SOURCE_CATEGORIES.FILE_SHARING, SOURCE_CATEGORIES.STORAGE, @@ -584,7 +575,8 @@ export const staticSourceData: SourceDataItem[] = [ SOURCE_CATEGORIES.MICROSOFT, SOURCE_CATEGORIES.OFFICE_365, ], - serviceType: 'share_point_server', // this doesn't exist on the BE + serviceType: 'custom', + baseServiceType: 'share_point_server', configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -594,12 +586,9 @@ export const staticSourceData: SourceDataItem[] = [ githubRepository: 'elastic/enterprise-search-sharepoint-server-connector', }, accountContextOnly: false, - internalConnectorAvailable: false, - customConnectorAvailable: true, }, { name: SOURCE_NAMES.SLACK, - iconName: SOURCE_NAMES.SLACK, serviceType: 'slack', configuration: { isPublicKey: false, @@ -617,17 +606,16 @@ export const staticSourceData: SourceDataItem[] = [ platinumPrivateContext: [FeatureIds.Remote, FeatureIds.Private, FeatureIds.SearchableContent], }, accountContextOnly: true, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.TEAMS, - iconName: SOURCE_NAMES.TEAMS, categories: [ SOURCE_CATEGORIES.COMMUNICATIONS, SOURCE_CATEGORIES.PRODUCTIVITY, SOURCE_CATEGORIES.MICROSOFT, ], - serviceType: 'teams', // this doesn't exist on the BE + serviceType: 'custom', + baseServiceType: 'teams', configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -637,12 +625,9 @@ export const staticSourceData: SourceDataItem[] = [ githubRepository: 'elastic/enterprise-search-teams-connector', }, accountContextOnly: false, - internalConnectorAvailable: false, - customConnectorAvailable: true, }, { name: SOURCE_NAMES.ZENDESK, - iconName: SOURCE_NAMES.ZENDESK, serviceType: 'zendesk', configuration: { isPublicKey: false, @@ -667,13 +652,12 @@ export const staticSourceData: SourceDataItem[] = [ ], }, accountContextOnly: false, - internalConnectorAvailable: true, }, { name: SOURCE_NAMES.ZOOM, - iconName: SOURCE_NAMES.ZOOM, categories: [SOURCE_CATEGORIES.COMMUNICATIONS, SOURCE_CATEGORIES.PRODUCTIVITY], - serviceType: 'zoom', // this doesn't exist on the BE + serviceType: 'custom', + baseServiceType: 'zoom', configuration: { isPublicKey: false, hasOauthRedirect: false, @@ -683,14 +667,12 @@ export const staticSourceData: SourceDataItem[] = [ githubRepository: 'elastic/enterprise-search-zoom-connector', }, accountContextOnly: false, - internalConnectorAvailable: false, - customConnectorAvailable: true, }, + staticGenericExternalSourceData, ]; export const staticCustomSourceData: SourceDataItem = { name: SOURCE_NAMES.CUSTOM, - iconName: SOURCE_NAMES.CUSTOM, categories: ['API', 'Custom'], serviceType: 'custom', configuration: { @@ -701,12 +683,26 @@ export const staticCustomSourceData: SourceDataItem = { applicationPortalUrl: '', }, accountContextOnly: false, - customConnectorAvailable: true, }; -export const getSourceData = (serviceType: string): SourceDataItem => { - return ( - staticSourceData.find((staticSource) => staticSource.serviceType === serviceType) || - staticCustomSourceData +export const getSourceData = ( + serviceType: string, + baseServiceType?: string +): SourceDataItem | undefined => { + if (serviceType === 'custom' && typeof baseServiceType === 'undefined') { + return staticCustomSourceData; + } + return staticSourceData.find( + (staticSource) => + staticSource.serviceType === serviceType && staticSource.baseServiceType === baseServiceType ); }; + +export const hasExternalConnectorOption = (serviceType: string): boolean => + !!getSourceData('external', serviceType); + +export const hasCustomConnectorOption = (serviceType: string): boolean => + !!getSourceData('custom', serviceType); + +export const hasMultipleConnectorOptions = (serviceType: string): boolean => + hasExternalConnectorOption(serviceType) || hasCustomConnectorOption(serviceType); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts index 0f113ad402f284a..0fdb827f6011df4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.test.ts @@ -23,7 +23,12 @@ import { itShowsServerErrorAsFlashMessage } from '../../../test_helpers'; import { AppLogic } from '../../app_logic'; import { staticSourceData } from './source_data'; -import { SourcesLogic, fetchSourceStatuses, POLLING_INTERVAL } from './sources_logic'; +import { + SourcesLogic, + fetchSourceStatuses, + POLLING_INTERVAL, + mergeServerAndStaticData, +} from './sources_logic'; describe('SourcesLogic', () => { const { http } = mockHttpValues; @@ -37,8 +42,14 @@ describe('SourcesLogic', () => { const defaultValues = { contentSources: [], privateContentSources: [], - sourceData: staticSourceData.map((data) => ({ ...data, connected: false })), - availableSources: staticSourceData.map((data) => ({ ...data, connected: false })), + sourceData: mergeServerAndStaticData([], staticSourceData, []).map((data) => ({ + ...data, + connected: false, + })), + availableSources: mergeServerAndStaticData([], staticSourceData, []).map((data) => ({ + ...data, + connected: false, + })), configuredSources: [], serviceTypes: [], permissionsModal: null, @@ -322,7 +333,7 @@ describe('SourcesLogic', () => { it('availableSources & configuredSources have correct length', () => { SourcesLogic.actions.onInitializeSources(serverResponse); - expect(SourcesLogic.values.availableSources).toHaveLength(18); + expect(SourcesLogic.values.availableSources).toHaveLength(19); expect(SourcesLogic.values.configuredSources).toHaveLength(5); }); it('externalConfigured is set to true if external is configured', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts index 868831ab7c7fb8e..0f61ee580f67733 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_logic.ts @@ -51,7 +51,7 @@ export interface IPermissionsModalProps { additionalConfiguration: boolean; } -type CombinedDataItem = SourceDataItem & { connected: boolean }; +type CombinedDataItem = SourceDataItem & Partial & { connected: boolean }; export interface ISourcesValues { contentSources: ContentSourceDetails[]; @@ -145,17 +145,17 @@ export const SourcesLogic = kea>( selectors: ({ selectors }) => ({ availableSources: [ () => [selectors.sourceData], - (sourceData: SourceDataItem[]) => + (sourceData: CombinedDataItem[]) => sortByName(sourceData.filter(({ configured }) => !configured)), ], configuredSources: [ () => [selectors.sourceData], - (sourceData: SourceDataItem[]) => + (sourceData: CombinedDataItem[]) => sortByName(sourceData.filter(({ configured }) => configured)), ], externalConfigured: [ () => [selectors.configuredSources], - (configuredSources: SourceDataItem[]) => + (configuredSources: CombinedDataItem[]) => !!configuredSources.find((item) => item.serviceType === 'external'), ], sourceData: [ @@ -312,9 +312,12 @@ export const mergeServerAndStaticData = ( contentSources: ContentSourceDetails[] ): CombinedDataItem[] => { const unsortedData = staticData.map((staticItem) => { - const serverItem = serverData.find(({ serviceType }) => serviceType === staticItem.serviceType); + const serverItem = staticItem.baseServiceType + ? undefined // static items with base service types will never have matching external connectors, BE doesn't pass us a baseServiceType + : serverData.find(({ serviceType }) => serviceType === staticItem.serviceType); const connectedSource = contentSources.find( - ({ serviceType }) => serviceType === staticItem.serviceType + ({ baseServiceType, serviceType }) => + serviceType === staticItem.serviceType && baseServiceType === staticItem.baseServiceType ); return { ...staticItem, diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx index 0fa263beab53956..07baa82a5cdb0cf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.test.tsx @@ -10,11 +10,11 @@ import '../../../__mocks__/shallow_useeffect.mock'; import { setMockValues, setMockActions } from '../../../__mocks__/kea_logic'; import React from 'react'; -import { Route, Switch, Redirect } from 'react-router-dom'; +import { Redirect } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { ADD_SOURCE_PATH, PRIVATE_SOURCES_PATH, SOURCES_PATH, getSourcesPath } from '../../routes'; +import { ADD_SOURCE_PATH, PRIVATE_SOURCES_PATH, getSourcesPath } from '../../routes'; import { SourcesRouter } from './sources_router'; @@ -34,19 +34,13 @@ describe('SourcesRouter', () => { }); it('renders sources routes', () => { - const TOTAL_ROUTES = 103; const wrapper = shallow(); - expect(wrapper.find(Switch)).toHaveLength(1); - expect(wrapper.find(Route)).toHaveLength(TOTAL_ROUTES); - }); - - it('redirects when nonplatinum license and accountOnly context', () => { - setMockValues({ ...mockValues, hasPlatinumLicense: false }); - const wrapper = shallow(); - - expect(wrapper.find(Redirect).last().prop('from')).toEqual(ADD_SOURCE_PATH); - expect(wrapper.find(Redirect).last().prop('to')).toEqual(SOURCES_PATH); + expect(wrapper.find('[data-test-subj="ConnectorIntroRoute"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="ConnectorChoiceRoute"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="ExternalConnectorConfigRoute"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="AddCustomSourceRoute"]')).toHaveLength(2); + expect(wrapper.find('[data-test-subj="AddSourceRoute"]')).toHaveLength(1); }); it('redirects when cannot create sources', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx index 19af955f8780ca2..4d4ec077213a013 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/sources_router.tsx @@ -11,7 +11,6 @@ import { Redirect, Route, Switch, useLocation } from 'react-router-dom'; import { Location } from 'history'; import { useActions, useValues } from 'kea'; -import { LicensingLogic } from '../../../shared/licensing'; import { AppLogic } from '../../app_logic'; import { GITHUB_ENTERPRISE_SERVER_VIA_APP_SERVICE_TYPE, @@ -24,17 +23,15 @@ import { SOURCES_PATH, getSourcesPath, getAddPath, - ADD_CUSTOM_PATH, } from '../../routes'; -import { hasMultipleConnectorOptions } from '../../utils'; import { AddSource, AddSourceList, GitHubViaApp } from './components/add_source'; import { AddCustomSource } from './components/add_source/add_custom_source'; import { ExternalConnectorConfig } from './components/add_source/add_external_connector'; -import { ConfigurationChoice } from './components/add_source/configuration_choice'; +import { AddSourceChoice } from './components/add_source/add_source_choice'; +import { AddSourceIntro } from './components/add_source/add_source_intro'; import { OrganizationSources } from './organization_sources'; import { PrivateSources } from './private_sources'; -import { staticCustomSourceData, staticSourceData as sources } from './source_data'; import { SourceRouter } from './source_router'; import { SourcesLogic } from './sources_logic'; @@ -42,7 +39,6 @@ import './sources.scss'; export const SourcesRouter: React.FC = () => { const { pathname } = useLocation() as Location; - const { hasPlatinumLicense } = useValues(LicensingLogic); const { resetSourcesState } = useActions(SourcesLogic); const { account: { canCreatePrivateSources }, @@ -82,119 +78,51 @@ export const SourcesRouter: React.FC = () => { - {sources.map((sourceData, i) => { - const { serviceType, externalConnectorAvailable, internalConnectorAvailable } = sourceData; - const path = `${getSourcesPath(getAddPath(serviceType), isOrganization)}`; - const defaultOption = internalConnectorAvailable - ? 'internal' - : externalConnectorAvailable - ? 'external' - : 'custom'; - const showChoice = defaultOption !== 'internal' && hasMultipleConnectorOptions(sourceData); - return ( - - {showChoice ? ( - - ) : ( - - )} - - ); - })} - - + + + + + + + + + + + + + + + + + - {sources - .filter((sourceData) => sourceData.internalConnectorAvailable) - .map((sourceData, i) => { - const { serviceType, accountContextOnly } = sourceData; - return ( - - {!hasPlatinumLicense && accountContextOnly ? ( - - ) : ( - - )} - - ); - })} - {sources - .filter((sourceData) => sourceData.externalConnectorAvailable) - .map((sourceData, i) => { - const { serviceType, accountContextOnly } = sourceData; - - return ( - - {!hasPlatinumLicense && accountContextOnly ? ( - - ) : ( - - )} - - ); - })} - {sources - .filter((sourceData) => sourceData.customConnectorAvailable) - .map((sourceData, i) => { - const { serviceType, accountContextOnly } = sourceData; - return ( - - {!hasPlatinumLicense && accountContextOnly ? ( - - ) : ( - - )} - - ); - })} - {sources.map((sourceData, i) => ( - - - - ))} - {sources.map((sourceData, i) => ( - - - - ))} - {sources.map((sourceData, i) => { - if (sourceData.configuration.needsConfiguration) - return ( - - - - ); - })} {canCreatePrivateSources ? ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx index 8399df946ea83cd..bc457ca0a1c00db 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx @@ -8,6 +8,7 @@ import '../../../../__mocks__/shallow_useeffect.mock'; import { setMockValues, setMockActions } from '../../../../__mocks__/kea_logic'; +import { mockUseParams } from '../../../../__mocks__/react_router'; import { sourceConfigData } from '../../../__mocks__/content_sources.mock'; import React from 'react'; @@ -18,8 +19,6 @@ import { EuiCallOut, EuiConfirmModal } from '@elastic/eui'; import { SaveConfig } from '../../content_sources/components/add_source/save_config'; -import { staticSourceData } from '../../content_sources/source_data'; - import { SourceConfig } from './source_config'; describe('SourceConfig', () => { @@ -30,10 +29,11 @@ describe('SourceConfig', () => { beforeEach(() => { setMockValues({ sourceConfigData, dataLoading: false }); setMockActions({ deleteSourceConfig, getSourceConfigData, saveSourceConfig }); + mockUseParams.mockReturnValue({ serviceType: 'share_point' }); }); it('renders', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -43,15 +43,23 @@ describe('SourceConfig', () => { expect(wrapper.find(EuiCallOut)).toHaveLength(0); }); + it('returns null if there is no matching source data for the service type', () => { + mockUseParams.mockReturnValue({ serviceType: 'doesnt_exist' }); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + it('renders a breadcrumb fallback while data is loading', () => { setMockValues({ dataLoading: true, sourceConfigData: {} }); - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.prop('pageChrome')).toEqual(['Settings', 'Content source connectors', '...']); }); it('handles delete click', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -63,7 +71,7 @@ describe('SourceConfig', () => { }); it('saves source config', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -75,7 +83,7 @@ describe('SourceConfig', () => { }); it('cancels and closes modal', () => { - const wrapper = shallow(); + const wrapper = shallow(); const saveConfig = wrapper.find(SaveConfig); // Trigger modal visibility @@ -87,9 +95,8 @@ describe('SourceConfig', () => { }); it('shows feedback link for external sources', () => { - const wrapper = shallow( - - ); + mockUseParams.mockReturnValue({ serviceType: 'external' }); + const wrapper = shallow(); expect(wrapper.find(EuiCallOut)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx index 6973732fa6727b4..76ed6023109d239 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx @@ -7,6 +7,8 @@ import React, { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; + import { useActions, useValues } from 'kea'; import { @@ -21,29 +23,34 @@ import { i18n } from '@kbn/i18n'; import { WorkplaceSearchPageTemplate } from '../../../components/layout'; import { NAV, REMOVE_BUTTON, CANCEL_BUTTON } from '../../../constants'; -import { SourceDataItem } from '../../../types'; import { AddSourceHeader } from '../../content_sources/components/add_source/add_source_header'; import { AddSourceLogic } from '../../content_sources/components/add_source/add_source_logic'; import { SaveConfig } from '../../content_sources/components/add_source/save_config'; +import { getSourceData } from '../../content_sources/source_data'; import { SettingsLogic } from '../settings_logic'; -interface SourceConfigProps { - sourceData: SourceDataItem; -} - -export const SourceConfig: React.FC = ({ sourceData }) => { +export const SourceConfig: React.FC = () => { + const { serviceType } = useParams<{ serviceType: string }>(); const [confirmModalVisible, setConfirmModalVisibility] = useState(false); - const { configuration, serviceType } = sourceData; + const addSourceLogic = AddSourceLogic({ serviceType }); const { deleteSourceConfig } = useActions(SettingsLogic); - const { saveSourceConfig, getSourceConfigData } = useActions(AddSourceLogic); + const { saveSourceConfig, getSourceConfigData, resetSourceState } = useActions(addSourceLogic); const { sourceConfigData: { name, categories }, dataLoading, - } = useValues(AddSourceLogic); + } = useValues(addSourceLogic); + const sourceData = getSourceData(serviceType); useEffect(() => { - getSourceConfigData(serviceType); - }, []); + getSourceConfigData(); + return resetSourceState; + }, [serviceType]); + + if (!sourceData) { + return null; + } + + const { configuration } = sourceData; const hideConfirmModal = () => setConfirmModalVisibility(false); const showConfirmModal = () => setConfirmModalVisibility(true); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx index 123167f0ad1d06e..604c15521572489 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx @@ -10,12 +10,10 @@ import '../../../__mocks__/shallow_useeffect.mock'; import { setMockActions } from '../../../__mocks__/kea_logic'; import React from 'react'; -import { Route, Redirect, Switch } from 'react-router-dom'; +import { Redirect, Switch } from 'react-router-dom'; import { shallow } from 'enzyme'; -import { staticSourceData } from '../content_sources/source_data'; - import { Connectors } from './components/connectors'; import { Customize } from './components/customize'; import { OauthApplication } from './components/oauth_application'; @@ -24,9 +22,6 @@ import { SettingsRouter } from './settings_router'; describe('SettingsRouter', () => { const initializeSettings = jest.fn(); - const NUM_SOURCES = staticSourceData.length; - // Should be 4 routes other than the sources listed: Connectors, Customize, & OauthApplication, & a redirect - const NUM_ROUTES = NUM_SOURCES + 4; beforeEach(() => { setMockActions({ initializeSettings }); @@ -36,11 +31,10 @@ describe('SettingsRouter', () => { const wrapper = shallow(); expect(wrapper.find(Switch)).toHaveLength(1); - expect(wrapper.find(Route)).toHaveLength(NUM_ROUTES); expect(wrapper.find(Redirect)).toHaveLength(1); expect(wrapper.find(Connectors)).toHaveLength(1); expect(wrapper.find(Customize)).toHaveLength(1); expect(wrapper.find(OauthApplication)).toHaveLength(1); - expect(wrapper.find(SourceConfig)).toHaveLength(NUM_SOURCES); + expect(wrapper.find(SourceConfig)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx index 7c5e501d6a2a128..fc250bbfbf4e422 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx @@ -16,7 +16,6 @@ import { ORG_SETTINGS_OAUTH_APPLICATION_PATH, getEditPath, } from '../../routes'; -import { staticSourceData } from '../content_sources/source_data'; import { Connectors } from './components/connectors'; import { Customize } from './components/customize'; @@ -42,11 +41,9 @@ export const SettingsRouter: React.FC = () => { - {staticSourceData.map((sourceData, i) => ( - - - - ))} + + + diff --git a/x-pack/plugins/event_log/server/event_logger.test.ts b/x-pack/plugins/event_log/server/event_logger.test.ts index f1f849bd3b17b5a..a9295ada0dd8560 100644 --- a/x-pack/plugins/event_log/server/event_logger.test.ts +++ b/x-pack/plugins/event_log/server/event_logger.test.ts @@ -143,6 +143,15 @@ describe('EventLogger', () => { expect(nanosToMillis(duration)).toBeCloseTo(timeStopValue - timeStartValue); }); + test('can set specific start time in startTiming', () => { + const event: IEvent = {}; + eventLogger.startTiming(event, new Date('2020-01-01T02:00:00.000Z')); + + const timeStart = event.event!.start!; + expect(timeStart).toBeTruthy(); + expect(timeStart).toEqual('2020-01-01T02:00:00.000Z'); + }); + test('timing method endTiming() method works when startTiming() is not called', async () => { const event: IEvent = {}; eventLogger.stopTiming(event); diff --git a/x-pack/plugins/event_log/server/event_logger.ts b/x-pack/plugins/event_log/server/event_logger.ts index 67d9dc61f4e1880..14cde6c191fa30a 100644 --- a/x-pack/plugins/event_log/server/event_logger.ts +++ b/x-pack/plugins/event_log/server/event_logger.ts @@ -47,11 +47,12 @@ export class EventLogger implements IEventLogger { this.systemLogger = ctorParams.systemLogger; } - startTiming(event: IEvent): void { + startTiming(event: IEvent, startTime?: Date): void { if (event == null) return; event.event = event.event || {}; - event.event.start = new Date().toISOString(); + const start = startTime ?? new Date(); + event.event.start = start.toISOString(); } stopTiming(event: IEvent): void { diff --git a/x-pack/plugins/event_log/server/types.ts b/x-pack/plugins/event_log/server/types.ts index 1336245741bd662..3291f162c09df07 100644 --- a/x-pack/plugins/event_log/server/types.ts +++ b/x-pack/plugins/event_log/server/types.ts @@ -66,7 +66,7 @@ export interface IEventLogClient { export interface IEventLogger { logEvent(properties: IEvent): void; - startTiming(event: IEvent): void; + startTiming(event: IEvent, startTime?: Date): void; stopTiming(event: IEvent): void; } diff --git a/x-pack/plugins/fleet/common/constants/routes.ts b/x-pack/plugins/fleet/common/constants/routes.ts index dcc7092356a9670..7b185960dcb7b7b 100644 --- a/x-pack/plugins/fleet/common/constants/routes.ts +++ b/x-pack/plugins/fleet/common/constants/routes.ts @@ -107,6 +107,7 @@ export const AGENT_API_ROUTES = { CHECKIN_PATTERN: `${API_ROOT}/agents/{agentId}/checkin`, ACKS_PATTERN: `${API_ROOT}/agents/{agentId}/acks`, ACTIONS_PATTERN: `${API_ROOT}/agents/{agentId}/actions`, + CANCEL_ACTIONS_PATTERN: `${API_ROOT}/agents/actions/{actionId}/cancel`, UNENROLL_PATTERN: `${API_ROOT}/agents/{agentId}/unenroll`, BULK_UNENROLL_PATTERN: `${API_ROOT}/agents/bulk_unenroll`, REASSIGN_PATTERN: `${API_ROOT}/agents/{agentId}/reassign`, @@ -117,6 +118,7 @@ export const AGENT_API_ROUTES = { STATUS_PATTERN_DEPRECATED: `${API_ROOT}/agent-status`, UPGRADE_PATTERN: `${API_ROOT}/agents/{agentId}/upgrade`, BULK_UPGRADE_PATTERN: `${API_ROOT}/agents/bulk_upgrade`, + CURRENT_UPGRADES_PATTERN: `${API_ROOT}/agents/current_upgrades`, }; export const ENROLLMENT_API_KEY_ROUTES = { diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index b3c37f5e567c397..dca3fd3ccb6789a 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -3795,6 +3795,9 @@ "source_uri": { "type": "string" }, + "rollout_duration_seconds": { + "type": "number" + }, "agents": { "oneOf": [ { diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index 36175ffc59a8892..d1a114b35ab6c5c 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -2391,6 +2391,8 @@ components: type: string source_uri: type: string + rollout_duration_seconds: + type: number agents: oneOf: - type: array diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/bulk_upgrade_agents.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/bulk_upgrade_agents.yaml index 31209d43fb58d27..74df244983a84fc 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/bulk_upgrade_agents.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/bulk_upgrade_agents.yaml @@ -5,6 +5,8 @@ properties: type: string source_uri: type: string + rollout_duration_seconds: + type: number agents: oneOf: - type: array diff --git a/x-pack/plugins/fleet/common/services/get_max_version.test.ts b/x-pack/plugins/fleet/common/services/get_max_version.test.ts new file mode 100644 index 000000000000000..6b21c81c0a5fe0e --- /dev/null +++ b/x-pack/plugins/fleet/common/services/get_max_version.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getMaxVersion } from './get_max_version'; + +describe('Fleet - getMaxVersion', () => { + it('returns the maximum version', () => { + const versions = ['8.1.0', '8.3.0', '8.2.1', '7.16.0', '8.2.0', '7.16.1', '8.3.1']; + expect(getMaxVersion(versions)).toEqual('8.3.1'); + }); + + it('returns the maximum version when there are duplicates', () => { + const versions = ['8.1.0', '8.3.0', '8.2.1', '7.16.0', '8.2.0', '7.16.1', '8.2.0', '7.15.1']; + expect(getMaxVersion(versions)).toEqual('8.3.0'); + }); + + it('returns the maximum version when there is a snapshot version', () => { + const versions = ['8.1.0', '8.2.0-SNAPSHOT', '7.16.0', '7.16.1']; + expect(getMaxVersion(versions)).toEqual('8.2.0-SNAPSHOT'); + }); + + it('returns the maximum version and prefers the major version to the snapshot', () => { + const versions = ['8.1.0', '8.2.0-SNAPSHOT', '8.2.0', '7.16.0', '7.16.1']; + expect(getMaxVersion(versions)).toEqual('8.2.0'); + }); + + it('when there is only a version returns it', () => { + const versions = ['8.1.0']; + expect(getMaxVersion(versions)).toEqual('8.1.0'); + }); + + it('returns an empty string when the passed array is empty', () => { + const versions: string[] = []; + expect(getMaxVersion(versions)).toEqual(''); + }); +}); diff --git a/x-pack/plugins/fleet/common/services/get_max_version.ts b/x-pack/plugins/fleet/common/services/get_max_version.ts new file mode 100644 index 000000000000000..e34dec675999d97 --- /dev/null +++ b/x-pack/plugins/fleet/common/services/get_max_version.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { uniq } from 'lodash'; +import semverGt from 'semver/functions/gt'; +import semverCoerce from 'semver/functions/coerce'; + +// Find max version from an array of string versions +export function getMaxVersion(versions: string[]) { + const uniqVersions: string[] = uniq(versions); + + if (uniqVersions.length === 1) { + const semverVersion = semverCoerce(uniqVersions[0])?.version; + return semverVersion ? semverVersion : ''; + } else if (uniqVersions.length > 1) { + const sorted = uniqVersions.sort((a, b) => (semverGt(a, b) ? 1 : -1)); + return sorted[sorted.length - 1]; + } + return ''; +} diff --git a/x-pack/plugins/fleet/common/services/validate_package_policy.test.ts b/x-pack/plugins/fleet/common/services/validate_package_policy.test.ts index 975d45fd01c644f..3a4676a4f9a7f83 100644 --- a/x-pack/plugins/fleet/common/services/validate_package_policy.test.ts +++ b/x-pack/plugins/fleet/common/services/validate_package_policy.test.ts @@ -241,6 +241,7 @@ describe('Fleet - validatePackagePolicy()', () => { ], }, ], + vars: {}, }; const invalidPackagePolicy: NewPackagePolicy = { @@ -332,6 +333,7 @@ describe('Fleet - validatePackagePolicy()', () => { ], }, ], + vars: {}, }; const noErrorsValidationResults = { @@ -370,6 +372,7 @@ describe('Fleet - validatePackagePolicy()', () => { vars: { 'var-name': null }, }, }, + vars: {}, }; it('returns no errors for valid package policy', () => { @@ -416,6 +419,7 @@ describe('Fleet - validatePackagePolicy()', () => { streams: { 'with-no-stream-vars-bar': {} }, }, }, + vars: {}, }); }); @@ -487,6 +491,7 @@ describe('Fleet - validatePackagePolicy()', () => { streams: { 'with-no-stream-vars-bar': {} }, }, }, + vars: {}, }); }); @@ -505,6 +510,7 @@ describe('Fleet - validatePackagePolicy()', () => { description: null, namespace: null, inputs: null, + vars: {}, }); expect( validatePackagePolicy( @@ -520,6 +526,7 @@ describe('Fleet - validatePackagePolicy()', () => { description: null, namespace: null, inputs: null, + vars: {}, }); }); @@ -538,6 +545,7 @@ describe('Fleet - validatePackagePolicy()', () => { description: null, namespace: null, inputs: null, + vars: {}, }); expect( validatePackagePolicy( @@ -553,6 +561,7 @@ describe('Fleet - validatePackagePolicy()', () => { description: null, namespace: null, inputs: null, + vars: {}, }); }); @@ -604,6 +613,7 @@ describe('Fleet - validatePackagePolicy()', () => { }, }, }, + vars: {}, }); }); @@ -729,6 +739,7 @@ describe('Fleet - validatePackagePolicy()', () => { }, }, }, + vars: {}, name: null, namespace: null, }); diff --git a/x-pack/plugins/fleet/common/services/validate_package_policy.ts b/x-pack/plugins/fleet/common/services/validate_package_policy.ts index 4d54fda6e5df5cb..3d0e8bed2aafadb 100644 --- a/x-pack/plugins/fleet/common/services/validate_package_policy.ts +++ b/x-pack/plugins/fleet/common/services/validate_package_policy.ts @@ -55,6 +55,7 @@ export const validatePackagePolicy = ( description: null, namespace: null, inputs: {}, + vars: {}, }; const namespaceValidation = isValidNamespace(packagePolicy.namespace); diff --git a/x-pack/plugins/fleet/common/types/models/agent.ts b/x-pack/plugins/fleet/common/types/models/agent.ts index 018f591fef79c80..85d17cf67cfd176 100644 --- a/x-pack/plugins/fleet/common/types/models/agent.ts +++ b/x-pack/plugins/fleet/common/types/models/agent.ts @@ -11,8 +11,6 @@ import type { AGENT_TYPE_TEMPORARY, } from '../../constants'; -import type { FullAgentPolicy } from './agent_policy'; - export type AgentType = | typeof AGENT_TYPE_EPHEMERAL | typeof AGENT_TYPE_PERMANENT @@ -36,12 +34,20 @@ export type AgentActionType = | 'UNENROLL' | 'UPGRADE' | 'SETTINGS' - | 'POLICY_REASSIGN'; + | 'POLICY_REASSIGN' + | 'CANCEL'; export interface NewAgentAction { type: AgentActionType; data?: any; + ack_data?: any; sent_at?: string; + agents: string[]; + created_at?: string; + id?: string; + expiration?: string; + start_time?: string; + minimum_execution_duration?: number; } export interface AgentAction extends NewAgentAction { @@ -49,41 +55,10 @@ export interface AgentAction extends NewAgentAction { data?: any; sent_at?: string; id: string; - agent_id: string; created_at: string; ack_data?: any; } -export interface AgentPolicyAction extends NewAgentAction { - id: string; - type: AgentActionType; - data: { - policy: FullAgentPolicy; - }; - policy_id: string; - policy_revision: number; - created_at: string; - ack_data?: any; -} - -interface CommonAgentActionSOAttributes { - type: AgentActionType; - sent_at?: string; - timestamp?: string; - created_at: string; - data?: string; - ack_data?: string; -} - -export type AgentActionSOAttributes = CommonAgentActionSOAttributes & { - agent_id: string; -}; -export type AgentPolicyActionSOAttributes = CommonAgentActionSOAttributes & { - policy_id: string; - policy_revision: number; -}; -export type BaseAgentActionSOAttributes = AgentActionSOAttributes | AgentPolicyActionSOAttributes; - export interface AgentMetadata { [x: string]: any; } @@ -104,6 +79,7 @@ interface AgentBase { last_checkin_status?: 'error' | 'online' | 'degraded' | 'updating'; user_provided_metadata: AgentMetadata; local_metadata: AgentMetadata; + tags?: string[]; } export interface Agent extends AgentBase { @@ -216,6 +192,10 @@ export interface FleetServerAgent { * The last acknowledged action sequence number for the Elastic Agent */ action_seq_no?: number; + /** + * A list of tags used for organizing/filtering agents + */ + tags?: string[]; } /** * An Elastic Agent metadata @@ -268,6 +248,17 @@ export interface FleetServerAgentAction { * The Agent IDs the action is intended for. No support for json.RawMessage with the current generator. Could be useful to lazy parse the agent ids */ agents?: string[]; + + /** + * Date when the agent should execute that agent. This field could be altered by Fleet server for progressive rollout of the action. + */ + start_time?: string; + + /** + * Minimun execution duration in seconds, used for progressive rollout of the action. + */ + minimum_execution_duration?: number; + /** * The opaque payload. */ diff --git a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts index 40570bc599053d4..aa256db95634adb 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/agent.ts @@ -34,7 +34,7 @@ export interface GetOneAgentResponse { export interface PostNewAgentActionRequest { body: { - action: NewAgentAction; + action: Omit; }; params: { agentId: string; diff --git a/x-pack/plugins/fleet/public/applications/fleet/app.tsx b/x-pack/plugins/fleet/public/applications/fleet/app.tsx index b871f6d4e690b6a..eb8b01d831cd518 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/app.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/app.tsx @@ -8,7 +8,7 @@ import type { FunctionComponent } from 'react'; import React, { memo, useEffect, useState } from 'react'; import type { AppMountParameters } from '@kbn/core/public'; -import { EuiCode, EuiEmptyPrompt, EuiErrorBoundary, EuiPanel } from '@elastic/eui'; +import { EuiCode, EuiEmptyPrompt, EuiErrorBoundary, EuiPanel, EuiPortal } from '@elastic/eui'; import type { History } from 'history'; import { Router, Redirect, Route, Switch, useRouteMatch } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -27,7 +27,7 @@ import type { FleetConfigType, FleetStartServices } from '../../plugin'; import { PackageInstallProvider } from '../integrations/hooks'; -import { useAuthz } from './hooks'; +import { useAuthz, useFlyoutContext } from './hooks'; import { ConfigContext, @@ -38,8 +38,15 @@ import { useBreadcrumbs, useStartServices, UIExtensionsContext, + FlyoutContextProvider, } from './hooks'; -import { Error, Loading, FleetSetupLoading } from './components'; +import { + Error, + Loading, + FleetSetupLoading, + AgentEnrollmentFlyout, + FleetServerFlyout, +} from './components'; import type { UIExtensionsStorage } from './types'; import { FLEET_ROUTING_PATHS } from './constants'; @@ -251,7 +258,7 @@ export const FleetAppContext: React.FC<{ notifications={startServices.notifications} theme$={theme$} > - {children} + {children} @@ -295,6 +302,8 @@ const FleetTopNav = memo( export const AppRoutes = memo( ({ setHeaderActionMenu }: { setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'] }) => { + const flyoutContext = useFlyoutContext(); + return ( <> @@ -343,6 +352,22 @@ export const AppRoutes = memo( }} /> + + {flyoutContext.isEnrollmentFlyoutOpen && ( + + flyoutContext.closeEnrollmentFlyout()} + /> + + )} + + {flyoutContext.isFleetServerFlyoutOpen && ( + + flyoutContext.closeFleetServerFlyout()} /> + + )} ); } diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/advanced_tab.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/advanced_tab.tsx index 87b4a1bda7ff7eb..5c5f87b19f9774c 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/advanced_tab.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/advanced_tab.tsx @@ -57,6 +57,7 @@ export const AdvancedTab: React.FunctionComponent = () => { serviceToken, fleetServerHost: fleetServerHostForm.fleetServerHost, fleetServerPolicyId, + deploymentMode, disabled: !Boolean(serviceToken), }), getConfirmFleetServerConnectionStep({ isFleetServerReady, disabled: !Boolean(serviceToken) }), diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/quick_start_tab.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/quick_start_tab.tsx index cf8abc2fe9e161a..758a34113efcd16 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/quick_start_tab.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/quick_start_tab.tsx @@ -29,6 +29,7 @@ export const QuickStartTab: React.FunctionComponent = () => { fleetServerHost: quickStartCreateForm.fleetServerHost, fleetServerPolicyId: quickStartCreateForm.fleetServerPolicyId, serviceToken: quickStartCreateForm.serviceToken, + deploymentMode: 'quickstart', disabled: quickStartCreateForm.status !== 'success', }), getConfirmFleetServerConnectionStep({ diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/steps/add_fleet_server_host.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/steps/add_fleet_server_host.tsx index e64e23f039f894c..70753e37f8e8a82 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/steps/add_fleet_server_host.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/steps/add_fleet_server_host.tsx @@ -104,8 +104,13 @@ export const AddFleetServerHostStepContent = ({ /> - - + + = ({ isFleetServerReady }) => { - const addAgentFlyout = useContext(agentFlyoutContext); + const flyoutContext = useFlyoutContext(); return isFleetServerReady ? ( <> @@ -53,7 +53,7 @@ const ConfirmFleetServerConnectionStepContent: React.FunctionComponent<{ - + ), }; @@ -51,7 +56,8 @@ const InstallFleetServerStepContent: React.FunctionComponent<{ serviceToken?: string; fleetServerHost?: string; fleetServerPolicyId?: string; -}> = ({ serviceToken, fleetServerHost, fleetServerPolicyId }) => { + deploymentMode: DeploymentMode; +}> = ({ serviceToken, fleetServerHost, fleetServerPolicyId, deploymentMode }) => { const kibanaVersion = useKibanaVersion(); const { output } = useDefaultOutput(); @@ -63,7 +69,7 @@ const InstallFleetServerStepContent: React.FunctionComponent<{ serviceToken ?? '', fleetServerPolicyId, fleetServerHost, - false, + deploymentMode === 'production', output?.ca_trusted_fingerprint, kibanaVersion ); diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.test.ts b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.test.ts index 89a246c5c626544..12c1af65f95555a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.test.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.test.ts @@ -17,9 +17,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip \\\\ - tar xzvf elastic-agent--linux-x86_64.zip \\\\ - cd elastic-agent--linux-x86_64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip + tar xzvf elastic-agent--linux-x86_64.zip + cd elastic-agent--linux-x86_64 sudo ./elastic-agent install \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1" @@ -34,9 +34,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--darwin-x86_64.tar.gz \\\\ - tar xzvf elastic-agent--darwin-x86_64.tar.gz \\\\ - cd elastic-agent--darwin-x86_64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--darwin-x86_64.tar.gz + tar xzvf elastic-agent--darwin-x86_64.tar.gz + cd elastic-agent--darwin-x86_64 sudo ./elastic-agent install \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1" @@ -51,9 +51,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.tar.gz -OutFile elastic-agent--windows-x86_64.tar.gz \` - Expand-Archive .\\\\elastic-agent--windows-x86_64.tar.gz \` - cd elastic-agent--windows-x86_64\` + "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.tar.gz -OutFile elastic-agent--windows-x86_64.tar.gz + Expand-Archive .\\\\elastic-agent--windows-x86_64.tar.gz + cd elastic-agent--windows-x86_64 .\\\\elastic-agent.exe install \` --fleet-server-es=http://elasticsearch:9200 \` --fleet-server-service-token=service-token-1" @@ -68,9 +68,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--x86_64.rpm \\\\ - tar xzvf elastic-agent--x86_64.rpm \\\\ - cd elastic-agent--x86_64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--x86_64.rpm + tar xzvf elastic-agent--x86_64.rpm + cd elastic-agent--x86_64 sudo elastic-agent enroll \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1" @@ -85,9 +85,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--amd64.deb \\\\ - tar xzvf elastic-agent--amd64.deb \\\\ - cd elastic-agent--amd64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--amd64.deb + tar xzvf elastic-agent--amd64.deb + cd elastic-agent--amd64 sudo elastic-agent enroll \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1" @@ -106,9 +106,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip \\\\ - tar xzvf elastic-agent--linux-x86_64.zip \\\\ - cd elastic-agent--linux-x86_64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip + tar xzvf elastic-agent--linux-x86_64.zip + cd elastic-agent--linux-x86_64 sudo ./elastic-agent install \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1 \\\\ @@ -127,9 +127,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip \\\\ - tar xzvf elastic-agent--linux-x86_64.zip \\\\ - cd elastic-agent--linux-x86_64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip + tar xzvf elastic-agent--linux-x86_64.zip + cd elastic-agent--linux-x86_64 sudo ./elastic-agent install \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1 \\\\ @@ -146,9 +146,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--darwin-x86_64.tar.gz \\\\ - tar xzvf elastic-agent--darwin-x86_64.tar.gz \\\\ - cd elastic-agent--darwin-x86_64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--darwin-x86_64.tar.gz + tar xzvf elastic-agent--darwin-x86_64.tar.gz + cd elastic-agent--darwin-x86_64 sudo ./elastic-agent install \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1 \\\\ @@ -165,9 +165,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.tar.gz -OutFile elastic-agent--windows-x86_64.tar.gz \` - Expand-Archive .\\\\elastic-agent--windows-x86_64.tar.gz \` - cd elastic-agent--windows-x86_64\` + "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.tar.gz -OutFile elastic-agent--windows-x86_64.tar.gz + Expand-Archive .\\\\elastic-agent--windows-x86_64.tar.gz + cd elastic-agent--windows-x86_64 .\\\\elastic-agent.exe install \` --fleet-server-es=http://elasticsearch:9200 \` --fleet-server-service-token=service-token-1 \` @@ -184,9 +184,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--x86_64.rpm \\\\ - tar xzvf elastic-agent--x86_64.rpm \\\\ - cd elastic-agent--x86_64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--x86_64.rpm + tar xzvf elastic-agent--x86_64.rpm + cd elastic-agent--x86_64 sudo elastic-agent enroll \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1 \\\\ @@ -203,9 +203,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--amd64.deb \\\\ - tar xzvf elastic-agent--amd64.deb \\\\ - cd elastic-agent--amd64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--amd64.deb + tar xzvf elastic-agent--amd64.deb + cd elastic-agent--amd64 sudo elastic-agent enroll \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1 \\\\ @@ -226,9 +226,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip \\\\ - tar xzvf elastic-agent--linux-x86_64.zip \\\\ - cd elastic-agent--linux-x86_64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip + tar xzvf elastic-agent--linux-x86_64.zip + cd elastic-agent--linux-x86_64 sudo ./elastic-agent install--url=http://fleetserver:8220 \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1 \\\\ @@ -251,9 +251,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--darwin-x86_64.tar.gz \\\\ - tar xzvf elastic-agent--darwin-x86_64.tar.gz \\\\ - cd elastic-agent--darwin-x86_64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--darwin-x86_64.tar.gz + tar xzvf elastic-agent--darwin-x86_64.tar.gz + cd elastic-agent--darwin-x86_64 sudo ./elastic-agent install --url=http://fleetserver:8220 \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1 \\\\ @@ -276,9 +276,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.tar.gz -OutFile elastic-agent--windows-x86_64.tar.gz \` - Expand-Archive .\\\\elastic-agent--windows-x86_64.tar.gz \` - cd elastic-agent--windows-x86_64\` + "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.tar.gz -OutFile elastic-agent--windows-x86_64.tar.gz + Expand-Archive .\\\\elastic-agent--windows-x86_64.tar.gz + cd elastic-agent--windows-x86_64 .\\\\elastic-agent.exe install --url=http://fleetserver:8220 \` --fleet-server-es=http://elasticsearch:9200 \` --fleet-server-service-token=service-token-1 \` @@ -301,9 +301,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--x86_64.rpm \\\\ - tar xzvf elastic-agent--x86_64.rpm \\\\ - cd elastic-agent--x86_64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--x86_64.rpm + tar xzvf elastic-agent--x86_64.rpm + cd elastic-agent--x86_64 sudo elastic-agent enroll --url=http://fleetserver:8220 \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1 \\\\ @@ -326,9 +326,9 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--amd64.deb \\\\ - tar xzvf elastic-agent--amd64.deb \\\\ - cd elastic-agent--amd64 \\\\ + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--amd64.deb + tar xzvf elastic-agent--amd64.deb + cd elastic-agent--amd64 sudo elastic-agent enroll --url=http://fleetserver:8220 \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ --fleet-server-service-token=service-token-1 \\\\ diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.ts b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.ts index 525af7cf95103fb..ed38478c3a3eee9 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.ts @@ -67,12 +67,12 @@ export function getInstallCommandForPlatform( `wget ${artifact.fullUrl} -OutFile ${artifact.filename}`, `Expand-Archive .\\${artifact.filename}`, `cd ${artifact.unpackedDir}`, - ].join(` ${newLineSeparator}`) + ].join(`\n`) : [ `curl -L -O ${artifact.fullUrl}`, `tar xzvf ${artifact.filename}`, `cd ${artifact.unpackedDir}`, - ].join(` ${newLineSeparator}`); + ].join(`\n`); const commandArguments = []; @@ -108,11 +108,11 @@ export function getInstallCommandForPlatform( }, ''); const commands = { - linux: `${downloadCommand} ${newLineSeparator}sudo ./elastic-agent install${commandArgumentsStr}`, - mac: `${downloadCommand} ${newLineSeparator}sudo ./elastic-agent install ${commandArgumentsStr}`, - windows: `${downloadCommand}${newLineSeparator}.\\elastic-agent.exe install ${commandArgumentsStr}`, - deb: `${downloadCommand} ${newLineSeparator}sudo elastic-agent enroll ${commandArgumentsStr}`, - rpm: `${downloadCommand} ${newLineSeparator}sudo elastic-agent enroll ${commandArgumentsStr}`, + linux: `${downloadCommand}\nsudo ./elastic-agent install${commandArgumentsStr}`, + mac: `${downloadCommand}\nsudo ./elastic-agent install ${commandArgumentsStr}`, + windows: `${downloadCommand}\n.\\elastic-agent.exe install ${commandArgumentsStr}`, + deb: `${downloadCommand}\nsudo elastic-agent enroll ${commandArgumentsStr}`, + rpm: `${downloadCommand}\nsudo elastic-agent enroll ${commandArgumentsStr}`, }; return commands[platform]; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.test.tsx index a15692b718a32a6..64fa3ad96fed340 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.test.tsx @@ -82,11 +82,17 @@ describe('StepDefinePackagePolicy', () => { }; }); - const validationResults = { name: null, description: null, namespace: null, inputs: {} }; + const validationResults = { + name: null, + description: null, + namespace: null, + inputs: {}, + vars: {}, + }; let testRenderer: TestRenderer; let renderResult: ReturnType; - const render = () => + const render = ({ isUpdate } = { isUpdate: false }) => (renderResult = testRenderer.render( { updatePackagePolicy={mockUpdatePackagePolicy} validationResults={validationResults} submitAttempted={false} + isUpdate={isUpdate} /> )); @@ -199,4 +206,23 @@ describe('StepDefinePackagePolicy', () => { expect(renderResult.getByDisplayValue('apache-11')).toBeInTheDocument(); }); }); + + describe('update', () => { + describe('when package vars are introduced in a new package version', () => { + it('should display new package vars', () => { + render({ isUpdate: true }); + + waitFor(async () => { + expect(renderResult.getByDisplayValue('showUserVarVal')).toBeInTheDocument(); + expect(renderResult.getByText('Required var')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(renderResult.getByText('Advanced options').closest('button')!); + }); + + expect(renderResult.getByText('Advanced var')).toBeInTheDocument(); + }); + }); + }); + }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx index 7f67452e2f230b2..893c68236aa6e78 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/step_define_package_policy.tsx @@ -90,9 +90,28 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ // Update package policy's package and agent policy info useEffect(() => { - if (isUpdate || isLoadingPackagePolicies) { + if (isLoadingPackagePolicies) { return; } + + if (isUpdate) { + // If we're upgrading, we need to make sure we catch an addition of package-level + // vars when they were previously no package-level vars defined + if (!packagePolicy.vars && packageInfo.vars) { + updatePackagePolicy( + packageToPackagePolicy( + packageInfo, + agentPolicy?.id || '', + packagePolicy.output_id, + packagePolicy.namespace, + packagePolicy.name, + packagePolicy.description, + integrationToEnable + ) + ); + } + } + const pkg = packagePolicy.package; const currentPkgKey = pkg ? pkgKeyFromPackageInfo(pkg) : ''; const pkgKey = pkgKeyFromPackageInfo(packageInfo); @@ -211,6 +230,7 @@ export const StepDefinePackagePolicy: React.FunctionComponent<{ const { name: varName, type: varType } = varDef; if (!packagePolicy.vars || !packagePolicy.vars[varName]) return null; const value = packagePolicy.vars[varName].value; + return ( props.theme.eui.euiZLevel5}; `; -interface Props extends EuiFlyoutProps { +interface Props extends Omit { onClose: (createdAgentPolicy?: AgentPolicy) => void; } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx index a511c2dc9f3da96..9f164d4aff13c6c 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx @@ -15,6 +15,7 @@ import { EuiFlexItem, EuiPopover, EuiPortal, + EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -23,6 +24,8 @@ import type { Agent, AgentPolicy } from '../../../../types'; import { AgentEnrollmentFlyout, SearchBar } from '../../../../components'; import { AGENTS_INDEX } from '../../../../constants'; +import { MAX_TAG_DISPLAY_LENGTH, truncateTag } from '../utils'; + import { AgentBulkActions } from './bulk_actions'; import type { SelectionMode } from './types'; @@ -70,6 +73,9 @@ export const SearchAndFilterBar: React.FunctionComponent<{ onSelectedStatusChange: (selectedStatus: string[]) => void; showUpgradeable: boolean; onShowUpgradeableChange: (showUpgradeable: boolean) => void; + tags: string[]; + selectedTags: string[]; + onSelectedTagsChange: (selectedTags: string[]) => void; totalAgents: number; totalInactiveAgents: number; selectionMode: SelectionMode; @@ -87,6 +93,9 @@ export const SearchAndFilterBar: React.FunctionComponent<{ onSelectedStatusChange, showUpgradeable, onShowUpgradeableChange, + tags, + selectedTags, + onSelectedTagsChange, totalAgents, totalInactiveAgents, selectionMode, @@ -100,7 +109,9 @@ export const SearchAndFilterBar: React.FunctionComponent<{ const [isAgentPoliciesFilterOpen, setIsAgentPoliciesFilterOpen] = useState(false); // Status for filtering - const [isStatusFilterOpen, setIsStatutsFilterOpen] = useState(false); + const [isStatusFilterOpen, setIsStatusFilterOpen] = useState(false); + + const [isTagsFilterOpen, setIsTagsFilterOpen] = useState(false); // Add a agent policy id to current search const addAgentPolicyFilter = (policyId: string) => { @@ -114,6 +125,14 @@ export const SearchAndFilterBar: React.FunctionComponent<{ ); }; + const addTagsFilter = (tag: string) => { + onSelectedTagsChange([...selectedTags, tag]); + }; + + const removeTagsFilter = (tag: string) => { + onSelectedTagsChange(selectedTags.filter((t) => t !== tag)); + }; + return ( <> {isEnrollmentFlyoutOpen ? ( @@ -146,7 +165,7 @@ export const SearchAndFilterBar: React.FunctionComponent<{ button={ setIsStatutsFilterOpen(!isStatusFilterOpen)} + onClick={() => setIsStatusFilterOpen(!isStatusFilterOpen)} isSelected={isStatusFilterOpen} hasActiveFilters={selectedStatus.length > 0} disabled={agentPolicies.length === 0} @@ -159,7 +178,7 @@ export const SearchAndFilterBar: React.FunctionComponent<{ } isOpen={isStatusFilterOpen} - closePopover={() => setIsStatutsFilterOpen(false)} + closePopover={() => setIsStatusFilterOpen(false)} panelPaddingSize="none" >
    @@ -180,6 +199,52 @@ export const SearchAndFilterBar: React.FunctionComponent<{ ))}
    + setIsTagsFilterOpen(!isTagsFilterOpen)} + isSelected={isTagsFilterOpen} + hasActiveFilters={selectedTags.length > 0} + numFilters={selectedTags.length} + disabled={tags.length === 0} + data-test-subj="agentList.tagsFilter" + > + + + } + isOpen={isTagsFilterOpen} + closePopover={() => setIsTagsFilterOpen(false)} + panelPaddingSize="none" + > +
    + {tags.map((tag, index) => ( + { + if (selectedTags.includes(tag)) { + removeTagsFilter(tag); + } else { + addTagsFilter(tag); + } + }} + > + {tag.length > MAX_TAG_DISPLAY_LENGTH ? ( + + {truncateTag(tag)} + + ) : ( + tag + )} + + ))} +
    +
    { + describe('when list is short', () => { + it('renders a comma-separated list of tags', () => { + const tags = ['tag1', 'tag2']; + render(); + + expect(screen.getByTestId('agentTags')).toHaveTextContent('tag1, tag2'); + }); + }); + + describe('when list is long', () => { + it('renders a truncated list of tags with full list displayed in tooltip on hover', async () => { + const tags = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5']; + render(); + + const tagsNode = screen.getByTestId('agentTags'); + + expect(tagsNode).toHaveTextContent('tag1, tag2, tag3 + 2 more'); + + fireEvent.mouseEnter(tagsNode); + await waitFor(() => { + screen.getByTestId('agentTagsTooltip'); + }); + + expect(screen.getByTestId('agentTagsTooltip')).toHaveTextContent( + 'tag1, tag2, tag3, tag4, tag5' + ); + }); + }); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx new file mode 100644 index 000000000000000..9e084b07e64d177 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/tags.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiToolTip } from '@elastic/eui'; +import { take } from 'lodash'; +import React from 'react'; + +import { truncateTag } from '../utils'; + +interface Props { + tags: string[]; +} + +// Number of tags displayed before "+ N more" is displayed +const MAX_TAGS_TO_DISPLAY = 3; + +export const Tags: React.FunctionComponent = ({ tags }) => { + return ( + <> + {tags.length > MAX_TAGS_TO_DISPLAY ? ( + <> + {tags.join(', ')}}> + + {take(tags, 3).map(truncateTag).join(', ')} + {tags.length - MAX_TAGS_TO_DISPLAY} more + + + + ) : ( + {tags.map(truncateTag).join(', ')} + )} + + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index 5776a163fd6a3dd..be38f7688c73571 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState, useMemo, useCallback, useRef, useEffect, useContext } from 'react'; +import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { EuiBasicTable, EuiFlexGroup, @@ -31,6 +31,7 @@ import { useBreadcrumbs, useKibanaVersion, useStartServices, + useFlyoutContext, } from '../../../hooks'; import { AgentEnrollmentFlyout, AgentPolicySummaryLine } from '../../../components'; import { AgentStatusKueryHelper, isAgentUpgradeable } from '../../../services'; @@ -45,11 +46,10 @@ import { } from '../components'; import { useFleetServerUnhealthy } from '../hooks/use_fleet_server_unhealthy'; -import { agentFlyoutContext } from '..'; - import { AgentTableHeader } from './components/table_header'; import type { SelectionMode } from './components/types'; import { SearchAndFilterBar } from './components/search_and_filter_bar'; +import { Tags } from './components/tags'; import { TableRowActions } from './components/table_row_actions'; import { EmptyPrompt } from './components/empty_prompt'; @@ -98,14 +98,21 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { // Status for filtering const [selectedStatus, setSelectedStatus] = useState([]); + const [selectedTags, setSelectedTags] = useState([]); + const isUsingFilter = - search.trim() || selectedAgentPolicies.length || selectedStatus.length || showUpgradeable; + search.trim() || + selectedAgentPolicies.length || + selectedStatus.length || + selectedTags.length || + showUpgradeable; const clearFilters = useCallback(() => { setDraftKuery(''); setSearch(''); setSelectedAgentPolicies([]); setSelectedStatus([]); + setSelectedTags([]); setShowUpgradeable(false); }, [setSearch, setDraftKuery, setSelectedAgentPolicies, setSelectedStatus, setShowUpgradeable]); @@ -117,7 +124,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { isOpen: false, }); - const flyoutContext = useContext(agentFlyoutContext); + const flyoutContext = useFlyoutContext(); // Agent actions states const [agentToReassign, setAgentToReassign] = useState(undefined); @@ -135,6 +142,16 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { .map((agentPolicy) => `"${agentPolicy}"`) .join(' or ')})`; } + + if (selectedTags.length) { + if (kueryBuilder) { + kueryBuilder = `(${kueryBuilder}) and`; + } + kueryBuilder = `${kueryBuilder} ${AGENTS_PREFIX}.tags : (${selectedTags + .map((tag) => `"${tag}"`) + .join(' or ')})`; + } + if (selectedStatus.length) { const kueryStatus = selectedStatus .map((status) => { @@ -164,7 +181,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { } return kueryBuilder; - }, [selectedStatus, selectedAgentPolicies, search]); + }, [search, selectedAgentPolicies, selectedTags, selectedStatus]); const showInactive = useMemo(() => { return selectedStatus.includes('inactive'); @@ -174,6 +191,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { const [agentsStatus, setAgentsStatus] = useState< { [key in SimplifiedAgentStatus]: number } | undefined >(); + const [allTags, setAllTags] = useState(); const [isLoading, setIsLoading] = useState(false); const [totalAgents, setTotalAgents] = useState(0); const [totalInactiveAgents, setTotalInactiveAgents] = useState(0); @@ -224,6 +242,16 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { inactive: agentsRequest.data.totalInactive, }); + // Only set tags on the first request - we don't want the list of tags to update based + // on the returned set of agents from the API + if (allTags === undefined) { + const newAllTags = Array.from( + new Set(agentsRequest.data.items.flatMap((agent) => agent.tags ?? [])) + ); + + setAllTags(newAllTags); + } + setAgents(agentsRequest.data.items); setTotalAgents(agentsRequest.data.total); setTotalInactiveAgents(agentsRequest.data.totalInactive); @@ -237,7 +265,15 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { setIsLoading(false); } fetchDataAsync(); - }, [pagination, kuery, showInactive, showUpgradeable, notifications.toasts]); + }, [ + pagination.currentPage, + pagination.pageSize, + kuery, + showInactive, + showUpgradeable, + allTags, + notifications.toasts, + ]); // Send request to get agent list and status useEffect(() => { @@ -296,7 +332,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { // Fleet server unhealthy status const { isUnhealthy: isFleetServerUnhealthy } = useFleetServerUnhealthy(); const onClickAddFleetServer = useCallback(() => { - flyoutContext?.openFleetServerFlyout(); + flyoutContext.openFleetServerFlyout(); }, [flyoutContext]); const columns = [ @@ -305,6 +341,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { name: i18n.translate('xpack.fleet.agentList.hostColumnTitle', { defaultMessage: 'Host', }), + width: '185px', render: (host: string, agent: Agent) => ( {safeMetadata(host)} @@ -313,23 +350,32 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { }, { field: 'active', - width: '120px', + width: '85px', name: i18n.translate('xpack.fleet.agentList.statusColumnTitle', { defaultMessage: 'Status', }), render: (active: boolean, agent: any) => , }, + { + field: 'tags', + width: '210px', + name: i18n.translate('xpack.fleet.agentList.tagsColumnTitle', { + defaultMessage: 'Tags', + }), + render: (tags: string[] = [], agent: any) => , + }, { field: 'policy_id', name: i18n.translate('xpack.fleet.agentList.policyColumnTitle', { defaultMessage: 'Agent policy', }), + width: '260px', render: (policyId: string, agent: Agent) => { const agentPolicy = agentPoliciesIndexedById[policyId]; const showWarning = agent.policy_revision && agentPolicy?.revision > agent.policy_revision; return ( - + {agentPolicy && } {showWarning && ( @@ -349,7 +395,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { }, { field: 'local_metadata.elastic.agent.version', - width: '200px', + width: '120px', name: i18n.translate('xpack.fleet.agentList.versionTitle', { defaultMessage: 'Version', }), @@ -378,6 +424,7 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { name: i18n.translate('xpack.fleet.agentList.lastCheckinTitle', { defaultMessage: 'Last activity', }), + width: '180px', render: (lastCheckin: string, agent: any) => lastCheckin ? : null, }, @@ -481,6 +528,9 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { onSelectedStatusChange={setSelectedStatus} showUpgradeable={showUpgradeable} onShowUpgradeableChange={setShowUpgradeable} + tags={allTags ?? []} + selectedTags={selectedTags} + onSelectedTagsChange={setSelectedTags} totalAgents={totalAgents} totalInactiveAgents={totalInactiveAgents} selectionMode={selectionMode} diff --git a/x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/index.ts similarity index 82% rename from x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts rename to x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/index.ts index 97f7fb61ae607de..a549209ac600539 100644 --- a/x-pack/plugins/security_solution/public/management/components/endpoint_console/index.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { EndpointConsole } from './endpoint_console'; +export * from './truncate_tag'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/truncate_tag.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/truncate_tag.ts new file mode 100644 index 000000000000000..57046a4b284b91b --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/utils/truncate_tag.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// Number of characters to display for each tag before truncation +export const MAX_TAG_DISPLAY_LENGTH = 20; + +export function truncateTag(tag: string) { + return tag.length > MAX_TAG_DISPLAY_LENGTH + ? `${tag.substring(0, MAX_TAG_DISPLAY_LENGTH)}...` + : tag; +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/enrollment_recommendation.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/enrollment_recommendation.tsx index 86990d84d513057..409a259f934dd29 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/enrollment_recommendation.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/enrollment_recommendation.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useContext } from 'react'; +import React from 'react'; import { EuiButton, EuiButtonEmpty, @@ -17,14 +17,12 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useStartServices } from '../../../../hooks'; - -import { agentFlyoutContext } from '../..'; +import { useFlyoutContext, useStartServices } from '../../../../hooks'; export const EnrollmentRecommendation: React.FunctionComponent<{ showStandaloneTab: () => void; }> = ({ showStandaloneTab }) => { - const flyoutContext = useContext(agentFlyoutContext); + const flyoutContext = useFlyoutContext(); const { docLinks } = useStartServices(); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/fleet_server_upgrade_modal.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/fleet_server_upgrade_modal.tsx index 5902f73cae3bc9a..0f9a8a3bbdd5087 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/fleet_server_upgrade_modal.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/components/fleet_server_upgrade_modal.tsx @@ -164,7 +164,7 @@ export const FleetServerUpgradeModal: React.FunctionComponent = ({ onClos ), link: ( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx index 57da2fcf36d760a..78ceb6293d3cea5 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx @@ -5,14 +5,21 @@ * 2.0. */ -import React, { createContext, useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { Router, Route, Switch, useHistory } from 'react-router-dom'; -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiPortal } from '@elastic/eui'; +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { FLEET_ROUTING_PATHS } from '../../constants'; -import { Loading, Error, AgentEnrollmentFlyout, FleetServerFlyout } from '../../components'; -import { useConfig, useFleetStatus, useBreadcrumbs, useAuthz, useGetSettings } from '../../hooks'; +import { Loading, Error } from '../../components'; +import { + useConfig, + useFleetStatus, + useBreadcrumbs, + useAuthz, + useGetSettings, + useFlyoutContext, +} from '../../hooks'; import { DefaultLayout, WithoutHeaderLayout } from '../../layouts'; import { AgentListPage } from './agent_list_page'; @@ -21,30 +28,16 @@ import { AgentDetailsPage } from './agent_details_page'; import { NoAccessPage } from './error_pages/no_access'; import { FleetServerUpgradeModal } from './components/fleet_server_upgrade_modal'; -// TODO: Move all instances of toggling these flyouts to a global context object to avoid cases in which -// we can render duplicate "stacked" flyouts -export const agentFlyoutContext = createContext< - | { - openEnrollmentFlyout: () => void; - closeEnrollmentFlyout: () => void; - openFleetServerFlyout: () => void; - closeFleetServerFlyout: () => void; - } - | undefined ->(undefined); - export const AgentsApp: React.FunctionComponent = () => { useBreadcrumbs('agent_list'); const history = useHistory(); const { agents } = useConfig(); const hasFleetAllPrivileges = useAuthz().fleet.all; const fleetStatus = useFleetStatus(); + const flyoutContext = useFlyoutContext(); const settings = useGetSettings(); - const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState(false); - const [isFleetServerFlyoutOpen, setIsFleetServerFlyoutOpen] = useState(false); - const [fleetServerModalVisible, setFleetServerModalVisible] = useState(false); const onCloseFleetServerModal = useCallback(() => { setFleetServerModalVisible(false); @@ -100,7 +93,7 @@ export const AgentsApp: React.FunctionComponent = () => { setIsEnrollmentFlyoutOpen(true)} + onClick={() => flyoutContext.openEnrollmentFlyout()} data-test-subj="addAgentBtnTop" > @@ -111,49 +104,24 @@ export const AgentsApp: React.FunctionComponent = () => { ) : undefined; return ( - setIsEnrollmentFlyoutOpen(true), - closeEnrollmentFlyout: () => setIsEnrollmentFlyoutOpen(false), - openFleetServerFlyout: () => setIsFleetServerFlyoutOpen(true), - closeFleetServerFlyout: () => setIsFleetServerFlyoutOpen(false), - }} - > - - - - - - - - {fleetServerModalVisible && ( - - )} - {hasOnlyFleetServerMissingRequirement ? ( - - ) : ( - - )} - - - - - {isEnrollmentFlyoutOpen && ( - - setIsEnrollmentFlyoutOpen(false)} - /> - - )} - - {isFleetServerFlyoutOpen && ( - - setIsFleetServerFlyoutOpen(false)} /> - - )} - - + + + + + + + + {fleetServerModalVisible && ( + + )} + {hasOnlyFleetServerMissingRequirement ? ( + + ) : ( + + )} + + + + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx index 9a95b9e834ec1d0..d76bb1ca7d0351b 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx @@ -80,12 +80,21 @@ export function validateLogstashHosts(value: string[]) { throw new Error('Invalid host'); } } catch (error) { - res.push({ - message: i18n.translate('xpack.fleet.settings.outputForm.logstashHostError', { - defaultMessage: 'Invalid Host', - }), - index: idx, - }); + if (val.length === 0) { + res.push({ + message: i18n.translate('xpack.fleet.settings.outputForm.logstashHostRequiredError', { + defaultMessage: 'Host is required', + }), + index: idx, + }); + } else { + res.push({ + message: i18n.translate('xpack.fleet.settings.outputForm.logstashHostError', { + defaultMessage: 'Invalid Host', + }), + index: idx, + }); + } } const curIndexes = urlIndexes[val] || []; diff --git a/x-pack/plugins/fleet/public/applications/integrations/app.tsx b/x-pack/plugins/fleet/public/applications/integrations/app.tsx index 717e528443a3f97..fb0d7f625488a5d 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/app.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/app.tsx @@ -7,7 +7,7 @@ import React, { memo } from 'react'; import type { AppMountParameters } from '@kbn/core/public'; -import { EuiErrorBoundary } from '@elastic/eui'; +import { EuiErrorBoundary, EuiPortal } from '@elastic/eui'; import type { History } from 'history'; import { Router, Redirect, Route, Switch } from 'react-router-dom'; import useObservable from 'react-use/lib/useObservable'; @@ -22,14 +22,17 @@ import type { FleetConfigType, FleetStartServices } from '../../plugin'; import { ConfigContext, FleetStatusProvider, KibanaVersionContext } from '../../hooks'; -import { AgentPolicyContextProvider } from './hooks'; +import { FleetServerFlyout } from '../fleet/components'; + +import { AgentPolicyContextProvider, useFlyoutContext } from './hooks'; import { INTEGRATIONS_ROUTING_PATHS, pagePathGetters } from './constants'; import type { UIExtensionsStorage } from './types'; import { EPMApp } from './sections/epm'; -import { PackageInstallProvider, UIExtensionsContext } from './hooks'; +import { PackageInstallProvider, UIExtensionsContext, FlyoutContextProvider } from './hooks'; import { IntegrationsHeader } from './components/header'; +import { AgentEnrollmentFlyout } from './components'; const EmptyContext = () => <>; @@ -81,9 +84,11 @@ export const IntegrationsAppContext: React.FC<{ notifications={startServices.notifications} theme$={theme$} > - - {children} - + + + {children} + + @@ -104,6 +109,8 @@ export const IntegrationsAppContext: React.FC<{ ); export const AppRoutes = memo(() => { + const flyoutContext = useFlyoutContext(); + return ( <> @@ -131,6 +138,22 @@ export const AppRoutes = memo(() => { }} /> + + {flyoutContext.isEnrollmentFlyoutOpen && ( + + flyoutContext.closeEnrollmentFlyout()} + /> + + )} + + {flyoutContext.isFleetServerFlyoutOpen && ( + + flyoutContext.closeFleetServerFlyout()} /> + + )} ); }); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/icon_panel.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/icon_panel.tsx index cc8b61e103be418..cb388ba4b74434f 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/icon_panel.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/icon_panel.tsx @@ -14,7 +14,7 @@ import { usePackageIconType } from '../../../../../hooks'; import { Loading } from '../../../../../components'; const Panel = styled(EuiPanel)` - padding: ${(props) => props.theme.eui.spacerSizes.xl}; + padding: ${(props) => props.theme.eui.euiSizeXL}; width: ${(props) => parseFloat(props.theme.eui.euiSize) * 6 + parseFloat(props.theme.eui.euiSizeXL) * 2}px; svg, diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx index 9202d89d7c93b11..05ff443a7b0e6c0 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx @@ -48,8 +48,8 @@ import { UpdateButton } from './update_button'; import { UninstallButton } from './uninstall_button'; const SettingsTitleCell = styled.td` - padding-right: ${(props) => props.theme.eui.spacerSizes.xl}; - padding-bottom: ${(props) => props.theme.eui.spacerSizes.m}; + padding-right: ${(props) => props.theme.eui.euiSizeXL}; + padding-bottom: ${(props) => props.theme.eui.euiSizeM}; `; const NoteLabel = () => ( diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx index 944cb8f94cfb235..0898f099e3e8c8c 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx @@ -51,7 +51,7 @@ export const mapToCard = ( let uiInternalPathUrl; if (item.type === 'ui_link') { - uiInternalPathUrl = getAbsolutePath(item.uiInternalPath); + uiInternalPathUrl = item.uiExternalLink || getAbsolutePath(item.uiInternalPath); } else { let urlVersion = item.version; diff --git a/x-pack/plugins/fleet/public/components/enrollment_instructions/manual/index.tsx b/x-pack/plugins/fleet/public/components/enrollment_instructions/manual/index.tsx index 5b777803552fb02..ca7293a8c99c96b 100644 --- a/x-pack/plugins/fleet/public/components/enrollment_instructions/manual/index.tsx +++ b/x-pack/plugins/fleet/public/components/enrollment_instructions/manual/index.tsx @@ -26,8 +26,9 @@ tar xzvf elastic-agent-${kibanaVersion}-darwin-x86_64.tar.gz cd elastic-agent-${kibanaVersion}-darwin-x86_64 sudo ./elastic-agent install ${enrollArgs}`; - const windowsCommand = `wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent-${kibanaVersion}-windows-x86_64.zip -OutFile elastic-agent-${kibanaVersion}-windows-x86_64.zip -Expand-Archive .\\elastic-agent-${kibanaVersion}-windows-x86_64.zip + const windowsCommand = `$ProgressPreference = 'SilentlyContinue' +wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent-${kibanaVersion}-windows-x86_64.zip -OutFile elastic-agent-${kibanaVersion}-windows-x86_64.zip +Expand-Archive .\\elastic-agent-${kibanaVersion}-windows-x86_64.zip -DestinationPath . cd elastic-agent-${kibanaVersion}-windows-x86_64 .\\elastic-agent.exe install ${enrollArgs}`; diff --git a/x-pack/plugins/fleet/public/components/enrollment_instructions/standalone/index.tsx b/x-pack/plugins/fleet/public/components/enrollment_instructions/standalone/index.tsx index 75378cdc8637803..2d9326cf6cbb1c9 100644 --- a/x-pack/plugins/fleet/public/components/enrollment_instructions/standalone/index.tsx +++ b/x-pack/plugins/fleet/public/components/enrollment_instructions/standalone/index.tsx @@ -23,8 +23,9 @@ tar xzvf elastic-agent-${kibanaVersion}-darwin-x86_64.tar.gz cd elastic-agent-${kibanaVersion}-darwin-x86_64 sudo ./elastic-agent install`; - const STANDALONE_RUN_INSTRUCTIONS_WINDOWS = `wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent-${kibanaVersion}-windows-x86_64.zip -OutFile elastic-agent-${kibanaVersion}-windows-x86_64.zip -Expand-Archive .\elastic-agent-${kibanaVersion}-windows-x86_64.zip + const STANDALONE_RUN_INSTRUCTIONS_WINDOWS = `$ProgressPreference = 'SilentlyContinue' +wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent-${kibanaVersion}-windows-x86_64.zip -OutFile elastic-agent-${kibanaVersion}-windows-x86_64.zip +Expand-Archive .\elastic-agent-${kibanaVersion}-windows-x86_64.zip -DestinationPath . cd elastic-agent-${kibanaVersion}-windows-x86_64 .\\elastic-agent.exe install`; diff --git a/x-pack/plugins/fleet/public/components/platform_selector.tsx b/x-pack/plugins/fleet/public/components/platform_selector.tsx index ae18f56b4b3ac07..0209bf8e31fd6b3 100644 --- a/x-pack/plugins/fleet/public/components/platform_selector.tsx +++ b/x-pack/plugins/fleet/public/components/platform_selector.tsx @@ -50,6 +50,14 @@ export const PlatformSelector: React.FunctionComponent = ({ /> ); + const commandsByPlatform: Record = { + linux: linuxCommand, + mac: macCommand, + windows: windowsCommand, + deb: linuxDebCommand, + rpm: linuxRpmCommand, + }; + return ( <> {isK8s ? ( @@ -67,39 +75,22 @@ export const PlatformSelector: React.FunctionComponent = ({ })} /> - {platform === 'linux' && ( - - {linuxCommand} - - )} - {platform === 'mac' && ( - - {macCommand} - - )} - {platform === 'windows' && ( - - {windowsCommand} - - )} - {platform === 'deb' && ( - <> - {systemPackageCallout} - - - {linuxDebCommand} - - - )} - {platform === 'rpm' && ( + {(platform === 'deb' || platform === 'rpm') && ( <> {systemPackageCallout} - - {linuxRpmCommand} - )} + + {commandsByPlatform[platform]} + )} diff --git a/x-pack/plugins/fleet/public/hooks/index.ts b/x-pack/plugins/fleet/public/hooks/index.ts index 5c995131396b403..579d1ab5bc3de06 100644 --- a/x-pack/plugins/fleet/public/hooks/index.ts +++ b/x-pack/plugins/fleet/public/hooks/index.ts @@ -27,3 +27,4 @@ export * from './use_platform'; export * from './use_agent_policy_refresh'; export * from './use_package_installations'; export * from './use_agent_enrollment_flyout_data'; +export * from './use_flyout_context'; diff --git a/x-pack/plugins/fleet/public/hooks/use_flyout_context.tsx b/x-pack/plugins/fleet/public/hooks/use_flyout_context.tsx new file mode 100644 index 000000000000000..0ddc358ab2fbf1a --- /dev/null +++ b/x-pack/plugins/fleet/public/hooks/use_flyout_context.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { createContext, useContext, useState } from 'react'; + +const agentFlyoutContext = createContext< + | { + isEnrollmentFlyoutOpen: boolean; + openEnrollmentFlyout: () => void; + closeEnrollmentFlyout: () => void; + isFleetServerFlyoutOpen: boolean; + openFleetServerFlyout: () => void; + closeFleetServerFlyout: () => void; + } + | undefined +>(undefined); + +export const FlyoutContextProvider: React.FunctionComponent = ({ children }) => { + const [isEnrollmentFlyoutOpen, setIsEnrollmentFlyoutOpen] = useState(false); + const [isFleetServerFlyoutOpen, setIsFleetServerFlyoutOpen] = useState(false); + + return ( + setIsEnrollmentFlyoutOpen(true), + closeEnrollmentFlyout: () => setIsEnrollmentFlyoutOpen(false), + isFleetServerFlyoutOpen, + openFleetServerFlyout: () => setIsFleetServerFlyoutOpen(true), + closeFleetServerFlyout: () => setIsFleetServerFlyoutOpen(false), + }} + > + {children} + + ); +}; + +export const useFlyoutContext = () => { + const context = useContext(agentFlyoutContext); + + if (!context) { + throw new Error('useFlyoutContext must be used within a FlyoutContextProvider'); + } + + return context; +}; diff --git a/x-pack/plugins/fleet/server/errors/handlers.ts b/x-pack/plugins/fleet/server/errors/handlers.ts index b42d2d4e0e5a479..eea3ac5c35f5fef 100644 --- a/x-pack/plugins/fleet/server/errors/handlers.ts +++ b/x-pack/plugins/fleet/server/errors/handlers.ts @@ -19,6 +19,7 @@ import { appContextService } from '../services'; import { AgentNotFoundError, + AgentActionNotFoundError, AgentPolicyNameExistsError, ConcurrentInstallOperationError, IngestManagerError, @@ -65,6 +66,9 @@ const getHTTPResponseCode = (error: IngestManagerError): number => { if (error instanceof AgentNotFoundError) { return 404; } + if (error instanceof AgentActionNotFoundError) { + return 404; + } return 400; // Bad Request }; diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts index cb1de48ad19580f..1d1892f620e9320 100644 --- a/x-pack/plugins/fleet/server/errors/index.ts +++ b/x-pack/plugins/fleet/server/errors/index.ts @@ -34,6 +34,7 @@ export class PackageOutdatedError extends IngestManagerError {} export class AgentPolicyError extends IngestManagerError {} export class AgentPolicyNotFoundError extends IngestManagerError {} export class AgentNotFoundError extends IngestManagerError {} +export class AgentActionNotFoundError extends IngestManagerError {} export class AgentPolicyNameExistsError extends AgentPolicyError {} export class PackageUnsupportedMediaTypeError extends IngestManagerError {} export class PackageInvalidArchiveError extends IngestManagerError {} diff --git a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.test.ts b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.test.ts index 85f7ea672ecb46e..7e7edaae70012ae 100644 --- a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.test.ts +++ b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.test.ts @@ -86,6 +86,7 @@ describe('test actions handlers', () => { id: 'agent', }), createAgentAction: jest.fn().mockReturnValueOnce(agentAction), + cancelAgentAction: jest.fn(), } as jest.Mocked; const postNewAgentActionHandler = postNewAgentActionHandlerBuilder(actionsService); diff --git a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts index 4f3cad9edab2629..36c1fd8401584c1 100644 --- a/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts +++ b/x-pack/plugins/fleet/server/routes/agent/actions_handlers.ts @@ -10,7 +10,10 @@ import type { RequestHandler } from '@kbn/core/server'; import type { TypeOf } from '@kbn/config-schema'; -import type { PostNewAgentActionRequestSchema } from '../../types/rest_spec'; +import type { + PostNewAgentActionRequestSchema, + PostCancelActionRequestSchema, +} from '../../types/rest_spec'; import type { ActionsService } from '../../services/agents'; import type { PostNewAgentActionResponse } from '../../../common/types/rest_spec'; import { defaultIngestErrorHandler } from '../../errors'; @@ -33,7 +36,7 @@ export const postNewAgentActionHandlerBuilder = function ( const savedAgentAction = await actionsService.createAgentAction(esClient, { created_at: new Date().toISOString(), ...newAgentAction, - agent_id: agent.id, + agents: [agent.id], }); const body: PostNewAgentActionResponse = { @@ -46,3 +49,23 @@ export const postNewAgentActionHandlerBuilder = function ( } }; }; + +export const postCancelActionHandlerBuilder = function ( + actionsService: ActionsService +): RequestHandler, undefined, undefined> { + return async (context, request, response) => { + try { + const esClient = (await context.core).elasticsearch.client.asInternalUser; + + const action = await actionsService.cancelAgentAction(esClient, request.params.actionId); + + const body: PostNewAgentActionResponse = { + item: action, + }; + + return response.ok({ body }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); + } + }; +}; diff --git a/x-pack/plugins/fleet/server/routes/agent/index.ts b/x-pack/plugins/fleet/server/routes/agent/index.ts index 535bb780abe57a8..4f26f0994425254 100644 --- a/x-pack/plugins/fleet/server/routes/agent/index.ts +++ b/x-pack/plugins/fleet/server/routes/agent/index.ts @@ -20,6 +20,7 @@ import { PostBulkAgentReassignRequestSchema, PostAgentUpgradeRequestSchema, PostBulkAgentUpgradeRequestSchema, + PostCancelActionRequestSchema, } from '../../types'; import * as AgentService from '../../services/agents'; import type { FleetConfigType } from '../..'; @@ -35,7 +36,10 @@ import { postBulkAgentsReassignHandler, getAgentDataHandler, } from './handlers'; -import { postNewAgentActionHandlerBuilder } from './actions_handlers'; +import { + postNewAgentActionHandlerBuilder, + postCancelActionHandlerBuilder, +} from './actions_handlers'; import { postAgentUnenrollHandler, postBulkAgentsUnenrollHandler } from './unenroll_handler'; import { postAgentUpgradeHandler, postBulkAgentsUpgradeHandler } from './upgrade_handler'; @@ -96,6 +100,22 @@ export const registerAPIRoutes = (router: FleetAuthzRouter, config: FleetConfigT }, postNewAgentActionHandlerBuilder({ getAgent: AgentService.getAgentById, + cancelAgentAction: AgentService.cancelAgentAction, + createAgentAction: AgentService.createAgentAction, + }) + ); + + router.post( + { + path: AGENT_API_ROUTES.CANCEL_ACTIONS_PATTERN, + validate: PostCancelActionRequestSchema, + fleetAuthz: { + fleet: { all: true }, + }, + }, + postCancelActionHandlerBuilder({ + getAgent: AgentService.getAgentById, + cancelAgentAction: AgentService.cancelAgentAction, createAgentAction: AgentService.createAgentAction, }) ); diff --git a/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts b/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts index 13df9222c95246e..32c8276a9e5f8f6 100644 --- a/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts +++ b/x-pack/plugins/fleet/server/routes/agent/upgrade_handler.ts @@ -7,15 +7,24 @@ import type { RequestHandler } from '@kbn/core/server'; import type { TypeOf } from '@kbn/config-schema'; +import type { SavedObjectsClientContract, ElasticsearchClient } from '@kbn/core/server'; + import semverCoerce from 'semver/functions/coerce'; +import semverGt from 'semver/functions/gt'; import type { PostAgentUpgradeResponse, PostBulkAgentUpgradeResponse } from '../../../common/types'; import type { PostAgentUpgradeRequestSchema, PostBulkAgentUpgradeRequestSchema } from '../../types'; import * as AgentService from '../../services/agents'; import { appContextService } from '../../services'; import { defaultIngestErrorHandler } from '../../errors'; +import { SO_SEARCH_LIMIT } from '../../../common'; import { isAgentUpgradeable } from '../../../common/services'; -import { getAgentById } from '../../services/agents'; +import { getAgentById, getAgentsByKuery } from '../../services/agents'; +import { PACKAGE_POLICY_SAVED_OBJECT_TYPE, AGENTS_PREFIX } from '../../constants'; + +import { getMaxVersion } from '../../../common/services/get_max_version'; + +import { packagePolicyService } from '../../services/package_policy'; export const postAgentUpgradeHandler: RequestHandler< TypeOf, @@ -28,7 +37,7 @@ export const postAgentUpgradeHandler: RequestHandler< const { version, source_uri: sourceUri, force } = request.body; const kibanaVersion = appContextService.getKibanaVersion(); try { - checkVersionIsSame(version, kibanaVersion); + checkKibanaVersion(version, kibanaVersion); checkSourceUriAllowed(sourceUri); } catch (err) { return response.customError({ @@ -81,11 +90,18 @@ export const postBulkAgentsUpgradeHandler: RequestHandler< const coreContext = await context.core; const soClient = coreContext.savedObjects.client; const esClient = coreContext.elasticsearch.client.asInternalUser; - const { version, source_uri: sourceUri, agents, force } = request.body; + const { + version, + source_uri: sourceUri, + agents, + force, + rollout_duration_seconds: upgradeDurationSeconds, + } = request.body; const kibanaVersion = appContextService.getKibanaVersion(); try { - checkVersionIsSame(version, kibanaVersion); + checkKibanaVersion(version, kibanaVersion); checkSourceUriAllowed(sourceUri); + await checkFleetServerVersion(version, agents, soClient, esClient); } catch (err) { return response.customError({ statusCode: 400, @@ -102,6 +118,7 @@ export const postBulkAgentsUpgradeHandler: RequestHandler< sourceUri, version, force, + upgradeDurationSeconds, }; const results = await AgentService.sendUpgradeAgentsActions(soClient, esClient, upgradeOptions); const body = results.items.reduce((acc, so) => { @@ -118,17 +135,17 @@ export const postBulkAgentsUpgradeHandler: RequestHandler< } }; -export const checkVersionIsSame = (version: string, kibanaVersion: string) => { +export const checkKibanaVersion = (version: string, kibanaVersion: string) => { // get version number only in case "-SNAPSHOT" is in it const kibanaVersionNumber = semverCoerce(kibanaVersion)?.version; if (!kibanaVersionNumber) throw new Error(`kibanaVersion ${kibanaVersionNumber} is not valid`); const versionToUpgradeNumber = semverCoerce(version)?.version; if (!versionToUpgradeNumber) throw new Error(`version to upgrade ${versionToUpgradeNumber} is not valid`); - // temporarily only allow upgrading to the same version as the installed kibana version - if (kibanaVersionNumber !== versionToUpgradeNumber) + + if (semverGt(version, kibanaVersion)) throw new Error( - `cannot upgrade agent to ${versionToUpgradeNumber} because it is different than the installed kibana version ${kibanaVersionNumber}` + `cannot upgrade agent to ${versionToUpgradeNumber} because it is higher than the installed kibana version ${kibanaVersionNumber}` ); }; @@ -139,3 +156,67 @@ const checkSourceUriAllowed = (sourceUri?: string) => { ); } }; + +// Check the installed fleet server versions +// Allow upgrading if the agents to upgrade include fleet server agents +const checkFleetServerVersion = async ( + versionToUpgradeNumber: string, + agentsIds: string | string[], + soClient: SavedObjectsClientContract, + esClient: ElasticsearchClient +) => { + let packagePolicyData; + try { + packagePolicyData = await packagePolicyService.list(soClient, { + perPage: SO_SEARCH_LIMIT, + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name: fleet_server`, + }); + } catch (error) { + throw new Error(error.message); + } + const agentPoliciesIds = packagePolicyData?.items.map((item) => item.policy_id); + + if (agentPoliciesIds.length === 0) { + return; + } + + let agentsResponse; + try { + agentsResponse = await getAgentsByKuery(esClient, { + showInactive: false, + perPage: SO_SEARCH_LIMIT, + kuery: `${AGENTS_PREFIX}.policy_id:${agentPoliciesIds.map((id) => `"${id}"`).join(' or ')}`, + }); + } catch (error) { + throw new Error(error.message); + } + + const { agents: fleetServerAgents } = agentsResponse; + + if (fleetServerAgents.length === 0) { + return; + } + const fleetServerIds = fleetServerAgents.map((agent) => agent.id); + + let hasFleetServerAgents: boolean; + if (Array.isArray(agentsIds)) { + hasFleetServerAgents = agentsIds.some((id) => fleetServerIds.includes(id)); + } else { + hasFleetServerAgents = fleetServerIds.includes(agentsIds); + } + if (hasFleetServerAgents) { + return; + } + + const fleetServerVersions = fleetServerAgents.map( + (agent) => agent.local_metadata.elastic.agent.version + ) as string[]; + + const maxFleetServerVersion = getMaxVersion(fleetServerVersions); + + if (semverGt(versionToUpgradeNumber, maxFleetServerVersion)) { + throw new Error( + `cannot upgrade agent to ${versionToUpgradeNumber} because it is higher than the latest fleet server version ${maxFleetServerVersion}` + ); + } +}; diff --git a/x-pack/plugins/fleet/server/services/agents/actions.test.ts b/x-pack/plugins/fleet/server/services/agents/actions.test.ts new file mode 100644 index 000000000000000..2838f2204ad9672 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agents/actions.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; + +import { cancelAgentAction } from './actions'; + +describe('Agent actions', () => { + describe('cancelAgentAction', () => { + it('throw if the target action is not found', async () => { + const esClient = elasticsearchServiceMock.createInternalClient(); + esClient.search.mockResolvedValue({ + hits: { + hits: [], + }, + } as any); + await expect(() => cancelAgentAction(esClient, 'i-do-not-exists')).rejects.toThrowError( + /Action not found/ + ); + }); + + it('should create one CANCEL action for each action found', async () => { + const esClient = elasticsearchServiceMock.createInternalClient(); + esClient.search.mockResolvedValue({ + hits: { + hits: [ + { + _source: { + action_id: 'action1', + agents: ['agent1', 'agent2'], + expiration: '2022-05-12T18:16:18.019Z', + }, + }, + { + _source: { + action_id: 'action1', + agents: ['agent3', 'agent4'], + expiration: '2022-05-12T18:16:18.019Z', + }, + }, + ], + }, + } as any); + await cancelAgentAction(esClient, 'action1'); + + expect(esClient.create).toBeCalledTimes(2); + expect(esClient.create).toBeCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + type: 'CANCEL', + data: { target_id: 'action1' }, + agents: ['agent1', 'agent2'], + }), + }) + ); + expect(esClient.create).toBeCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + type: 'CANCEL', + data: { target_id: 'action1' }, + agents: ['agent3', 'agent4'], + }), + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/agents/actions.ts b/x-pack/plugins/fleet/server/services/agents/actions.ts index 3ea8060e8e4923e..afa65bfe91fb36c 100644 --- a/x-pack/plugins/fleet/server/services/agents/actions.ts +++ b/x-pack/plugins/fleet/server/services/agents/actions.ts @@ -8,52 +8,62 @@ import uuid from 'uuid'; import type { ElasticsearchClient } from '@kbn/core/server'; -import type { Agent, AgentAction, FleetServerAgentAction } from '../../../common/types/models'; -import { AGENT_ACTIONS_INDEX } from '../../../common/constants'; +import type { + Agent, + AgentAction, + NewAgentAction, + FleetServerAgentAction, +} from '../../../common/types/models'; +import { AGENT_ACTIONS_INDEX, SO_SEARCH_LIMIT } from '../../../common/constants'; +import { AgentActionNotFoundError } from '../../errors'; const ONE_MONTH_IN_MS = 2592000000; export async function createAgentAction( esClient: ElasticsearchClient, - newAgentAction: Omit + newAgentAction: NewAgentAction ): Promise { - const id = uuid.v4(); + const actionId = newAgentAction.id ?? uuid.v4(); + const timestamp = new Date().toISOString(); const body: FleetServerAgentAction = { - '@timestamp': new Date().toISOString(), - expiration: new Date(Date.now() + ONE_MONTH_IN_MS).toISOString(), - agents: [newAgentAction.agent_id], - action_id: id, + '@timestamp': timestamp, + expiration: newAgentAction.expiration ?? new Date(Date.now() + ONE_MONTH_IN_MS).toISOString(), + agents: newAgentAction.agents, + action_id: actionId, data: newAgentAction.data, type: newAgentAction.type, + start_time: newAgentAction.start_time, + minimum_execution_duration: newAgentAction.minimum_execution_duration, }; await esClient.create({ index: AGENT_ACTIONS_INDEX, - id, + id: uuid.v4(), body, refresh: 'wait_for', }); return { - id, + id: actionId, ...newAgentAction, + created_at: timestamp, }; } export async function bulkCreateAgentActions( esClient: ElasticsearchClient, - newAgentActions: Array> + newAgentActions: NewAgentAction[] ): Promise { const actions = newAgentActions.map((newAgentAction) => { - const id = uuid.v4(); + const id = newAgentAction.id ?? uuid.v4(); return { id, ...newAgentAction, - }; + } as AgentAction; }); if (actions.length === 0) { - return actions; + return []; } await esClient.bulk({ @@ -61,8 +71,10 @@ export async function bulkCreateAgentActions( body: actions.flatMap((action) => { const body: FleetServerAgentAction = { '@timestamp': new Date().toISOString(), - expiration: new Date(Date.now() + ONE_MONTH_IN_MS).toISOString(), - agents: [action.agent_id], + expiration: action.expiration ?? new Date(Date.now() + ONE_MONTH_IN_MS).toISOString(), + start_time: action.start_time, + minimum_execution_duration: action.minimum_execution_duration, + agents: action.agents, action_id: action.id, data: action.data, type: action.type, @@ -82,9 +94,57 @@ export async function bulkCreateAgentActions( return actions; } +export async function cancelAgentAction(esClient: ElasticsearchClient, actionId: string) { + const res = await esClient.search({ + index: AGENT_ACTIONS_INDEX, + query: { + bool: { + must: [ + { + term: { + action_id: actionId, + }, + }, + ], + }, + }, + size: SO_SEARCH_LIMIT, + }); + + if (res.hits.hits.length === 0) { + throw new AgentActionNotFoundError('Action not found'); + } + + const cancelActionId = uuid.v4(); + const now = new Date().toISOString(); + for (const hit of res.hits.hits) { + if (!hit._source || !hit._source.agents || !hit._source.action_id) { + continue; + } + await createAgentAction(esClient, { + id: cancelActionId, + type: 'CANCEL', + agents: hit._source.agents, + data: { + target_id: hit._source.action_id, + }, + created_at: now, + expiration: hit._source.expiration, + }); + } + + return { + created_at: now, + id: cancelActionId, + type: 'CANCEL', + } as AgentAction; +} + export interface ActionsService { getAgent: (esClient: ElasticsearchClient, agentId: string) => Promise; + cancelAgentAction: (esClient: ElasticsearchClient, actionId: string) => Promise; + createAgentAction: ( esClient: ElasticsearchClient, newAgentAction: Omit diff --git a/x-pack/plugins/fleet/server/services/agents/reassign.ts b/x-pack/plugins/fleet/server/services/agents/reassign.ts index d342e8d54bb84cc..c842bfb8f72c761 100644 --- a/x-pack/plugins/fleet/server/services/agents/reassign.ts +++ b/x-pack/plugins/fleet/server/services/agents/reassign.ts @@ -20,7 +20,7 @@ import { bulkUpdateAgents, } from './crud'; import type { GetAgentsOptions } from '.'; -import { createAgentAction, bulkCreateAgentActions } from './actions'; +import { createAgentAction } from './actions'; import { searchHitToAgent } from './helpers'; export async function reassignAgent( @@ -42,7 +42,7 @@ export async function reassignAgent( }); await createAgentAction(esClient, { - agent_id: agentId, + agents: [agentId], created_at: new Date().toISOString(), type: 'POLICY_REASSIGN', }); @@ -161,14 +161,11 @@ export async function reassignAgents( }); const now = new Date().toISOString(); - await bulkCreateAgentActions( - esClient, - agentsToUpdate.map((agent) => ({ - agent_id: agent.id, - created_at: now, - type: 'POLICY_REASSIGN', - })) - ); + await createAgentAction(esClient, { + agents: agentsToUpdate.map((agent) => agent.id), + created_at: now, + type: 'POLICY_REASSIGN', + }); return { items: orderedOut }; } diff --git a/x-pack/plugins/fleet/server/services/agents/saved_objects.ts b/x-pack/plugins/fleet/server/services/agents/saved_objects.ts index a26194ef6ddeb90..596c7db5d847278 100644 --- a/x-pack/plugins/fleet/server/services/agents/saved_objects.ts +++ b/x-pack/plugins/fleet/server/services/agents/saved_objects.ts @@ -5,18 +5,9 @@ * 2.0. */ -import Boom from '@hapi/boom'; import type { SavedObject } from '@kbn/core/server'; -import type { - Agent, - AgentSOAttributes, - AgentAction, - AgentPolicyAction, - AgentActionSOAttributes, - AgentPolicyActionSOAttributes, - BaseAgentActionSOAttributes, -} from '../../types'; +import type { Agent, AgentSOAttributes } from '../../types'; export function savedObjectToAgent(so: SavedObject): Agent { if (so.error) { @@ -33,58 +24,3 @@ export function savedObjectToAgent(so: SavedObject): Agent { packages: so.attributes.packages ?? [], }; } - -export function savedObjectToAgentAction(so: SavedObject): AgentAction; -export function savedObjectToAgentAction( - so: SavedObject -): AgentPolicyAction; -export function savedObjectToAgentAction( - so: SavedObject -): AgentAction | AgentPolicyAction { - if (so.error) { - if (so.error.statusCode === 404) { - throw Boom.notFound(so.error.message); - } - - throw new Error(so.error.message); - } - - // If it's an AgentPolicyAction - if (isPolicyActionSavedObject(so)) { - return { - id: so.id, - type: so.attributes.type, - created_at: so.attributes.created_at, - policy_id: so.attributes.policy_id, - policy_revision: so.attributes.policy_revision, - data: so.attributes.data ? JSON.parse(so.attributes.data) : undefined, - ack_data: so.attributes.ack_data ? JSON.parse(so.attributes.ack_data) : undefined, - }; - } - - if (!isAgentActionSavedObject(so)) { - throw new Error(`Malformed saved object AgentAction ${so.id}`); - } - - // If it's an AgentAction - return { - id: so.id, - type: so.attributes.type, - created_at: so.attributes.created_at, - agent_id: so.attributes.agent_id, - data: so.attributes.data ? JSON.parse(so.attributes.data) : undefined, - ack_data: so.attributes.ack_data ? JSON.parse(so.attributes.ack_data) : undefined, - }; -} - -export function isAgentActionSavedObject( - so: SavedObject -): so is SavedObject { - return (so.attributes as AgentActionSOAttributes).agent_id !== undefined; -} - -export function isPolicyActionSavedObject( - so: SavedObject -): so is SavedObject { - return (so.attributes as AgentPolicyActionSOAttributes).policy_id !== undefined; -} diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts index e6327c16c3ccc93..45f40916598a15b 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.test.ts @@ -96,7 +96,7 @@ describe('unenrollAgents (plural)', () => { await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll }); // calls ES update with correct values - const calledWith = esClient.bulk.mock.calls[1][0]; + const calledWith = esClient.bulk.mock.calls[0][0]; const ids = (calledWith as estypes.BulkRequest)?.body ?.filter((i: any) => i.update !== undefined) .map((i: any) => i.update._id); @@ -116,7 +116,7 @@ describe('unenrollAgents (plural)', () => { // calls ES update with correct values const onlyRegular = [agentInRegularDoc._id, agentInRegularDoc2._id]; - const calledWith = esClient.bulk.mock.calls[1][0]; + const calledWith = esClient.bulk.mock.calls[0][0]; const ids = (calledWith as estypes.BulkRequest)?.body ?.filter((i: any) => i.update !== undefined) .map((i: any) => i.update._id); @@ -175,7 +175,7 @@ describe('unenrollAgents (plural)', () => { await unenrollAgents(soClient, esClient, { agentIds: idsToUnenroll, force: true }); // calls ES update with correct values - const calledWith = esClient.bulk.mock.calls[1][0]; + const calledWith = esClient.bulk.mock.calls[0][0]; const ids = (calledWith as estypes.BulkRequest)?.body ?.filter((i: any) => i.update !== undefined) .map((i: any) => i.update._id); @@ -232,18 +232,6 @@ describe('unenrollAgents (plural)', () => { function createClientMock() { const soClientMock = savedObjectsClientMock.create(); - // need to mock .create & bulkCreate due to (bulk)createAgentAction(s) in unenrollAgent(s) - // @ts-expect-error - soClientMock.create.mockResolvedValue({ attributes: { agent_id: 'tata' } }); - soClientMock.bulkCreate.mockImplementation(async ([{ type, attributes }]) => { - return { - saved_objects: [await soClientMock.create(type, attributes)], - }; - }); - soClientMock.bulkUpdate.mockResolvedValue({ - saved_objects: [], - }); - soClientMock.get.mockImplementation(async (_, id) => { switch (id) { case regularAgentPolicySO.id: diff --git a/x-pack/plugins/fleet/server/services/agents/unenroll.ts b/x-pack/plugins/fleet/server/services/agents/unenroll.ts index 461caff1ada6c67..92dd0f1ba22f8e2 100644 --- a/x-pack/plugins/fleet/server/services/agents/unenroll.ts +++ b/x-pack/plugins/fleet/server/services/agents/unenroll.ts @@ -11,7 +11,7 @@ import type { Agent, BulkActionResult } from '../../types'; import * as APIKeyService from '../api_keys'; import { HostedAgentPolicyRestrictionRelatedError } from '../../errors'; -import { createAgentAction, bulkCreateAgentActions } from './actions'; +import { createAgentAction } from './actions'; import type { GetAgentsOptions } from './crud'; import { getAgentById, @@ -53,7 +53,7 @@ export async function unenrollAgent( } const now = new Date().toISOString(); await createAgentAction(esClient, { - agent_id: agentId, + agents: [agentId], created_at: now, type: 'UNENROLL', }); @@ -105,14 +105,11 @@ export async function unenrollAgents( await invalidateAPIKeysForAgents(agentsToUpdate); } else { // Create unenroll action for each agent - await bulkCreateAgentActions( - esClient, - agentsToUpdate.map((agent) => ({ - agent_id: agent.id, - created_at: now, - type: 'UNENROLL', - })) - ); + await createAgentAction(esClient, { + agents: agentsToUpdate.map((agent) => agent.id), + created_at: now, + type: 'UNENROLL', + }); } // Update the necessary agents diff --git a/x-pack/plugins/fleet/server/services/agents/upgrade.ts b/x-pack/plugins/fleet/server/services/agents/upgrade.ts index 36568ca6e000417..f1bd60d1eba9499 100644 --- a/x-pack/plugins/fleet/server/services/agents/upgrade.ts +++ b/x-pack/plugins/fleet/server/services/agents/upgrade.ts @@ -6,6 +6,7 @@ */ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server'; +import moment from 'moment'; import type { Agent, BulkActionResult } from '../../types'; import { agentPolicyService } from '..'; @@ -17,7 +18,7 @@ import { import { isAgentUpgradeable } from '../../../common/services'; import { appContextService } from '../app_context'; -import { bulkCreateAgentActions, createAgentAction } from './actions'; +import { createAgentAction } from './actions'; import type { GetAgentsOptions } from './crud'; import { getAgentDocuments, @@ -28,6 +29,8 @@ import { } from './crud'; import { searchHitToAgent } from './helpers'; +const MINIMUM_EXECUTION_DURATION_SECONDS = 1800; // 30m + function isMgetDoc(doc?: estypes.MgetResponseItem): doc is estypes.GetGetResult { return Boolean(doc && 'found' in doc); } @@ -59,7 +62,7 @@ export async function sendUpgradeAgentAction({ } await createAgentAction(esClient, { - agent_id: agentId, + agents: [agentId], created_at: now, data, ack_data: data, @@ -75,9 +78,10 @@ export async function sendUpgradeAgentsActions( soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, options: ({ agents: Agent[] } | GetAgentsOptions) & { - sourceUri: string | undefined; version: string; + sourceUri?: string | undefined; force?: boolean; + upgradeDurationSeconds?: number; } ) { // Full set of agents @@ -158,16 +162,22 @@ export async function sendUpgradeAgentsActions( source_uri: options.sourceUri, }; - await bulkCreateAgentActions( - esClient, - agentsToUpdate.map((agent) => ({ - agent_id: agent.id, - created_at: now, - data, - ack_data: data, - type: 'UPGRADE', - })) - ); + const rollingUpgradeOptions = options?.upgradeDurationSeconds + ? { + start_time: now, + minimum_execution_duration: MINIMUM_EXECUTION_DURATION_SECONDS, + expiration: moment().add(options?.upgradeDurationSeconds, 'seconds').toISOString(), + } + : {}; + + await createAgentAction(esClient, { + created_at: now, + data, + ack_data: data, + type: 'UPGRADE', + agents: agentsToUpdate.map((agent) => agent.id), + ...rollingUpgradeOptions, + }); await bulkUpdateAgents( esClient, diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/index.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/index.ts index 574534290214ae1..5f093a19157f977 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/index.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/index.ts @@ -5,6 +5,6 @@ * 2.0. */ -export { installPipelines, isTopLevelPipeline } from './install'; +export { prepareToInstallPipelines, isTopLevelPipeline } from './install'; export { deletePreviousPipelines, deletePipeline } from './remove'; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts index 49dae4d86b63950..da035a44c992147 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts @@ -6,13 +6,12 @@ */ import type { TransportRequestOptions } from '@elastic/elasticsearch'; -import type { ElasticsearchClient, Logger, SavedObjectsClientContract } from '@kbn/core/server'; +import type { ElasticsearchClient, Logger } from '@kbn/core/server'; import { ElasticsearchAssetType } from '../../../../types'; import type { EsAssetReference, RegistryDataStream, InstallablePackage } from '../../../../types'; import { getAsset, getPathParts } from '../../archive'; import type { ArchiveEntry } from '../../archive'; -import { updateEsAssetReferences } from '../../packages/install'; import { FLEET_FINAL_PIPELINE_CONTENT, FLEET_FINAL_PIPELINE_ID, @@ -36,23 +35,23 @@ export const isTopLevelPipeline = (path: string) => { ); }; -export const installPipelines = async ( +export const prepareToInstallPipelines = ( installablePackage: InstallablePackage, - paths: string[], - esClient: ElasticsearchClient, - savedObjectsClient: SavedObjectsClientContract, - logger: Logger, - esReferences: EsAssetReference[] -) => { + paths: string[] +): { + assetsToAdd: EsAssetReference[]; + install: (esClient: ElasticsearchClient, logger: Logger) => Promise; +} => { // unlike other ES assets, pipeline names are versioned so after a template is updated // it can be created pointing to the new template, without removing the old one and effecting data // so do not remove the currently installed pipelines here const dataStreams = installablePackage.data_streams; - const { name: pkgName, version: pkgVersion } = installablePackage; + const { version: pkgVersion } = installablePackage; const pipelinePaths = paths.filter((path) => isPipeline(path)); const topLevelPipelinePaths = paths.filter((path) => isTopLevelPipeline(path)); - if (!dataStreams?.length && topLevelPipelinePaths.length === 0) return []; + if (!dataStreams?.length && topLevelPipelinePaths.length === 0) + return { assetsToAdd: [], install: () => Promise.resolve() }; // get and save pipeline refs before installing pipelines let pipelineRefs = dataStreams @@ -85,41 +84,41 @@ export const installPipelines = async ( pipelineRefs = [...pipelineRefs, ...topLevelPipelineRefs]; - esReferences = await updateEsAssetReferences(savedObjectsClient, pkgName, esReferences, { + return { assetsToAdd: pipelineRefs, - }); - - const pipelines = dataStreams - ? dataStreams.reduce>>((acc, dataStream) => { - if (dataStream.ingest_pipeline) { - acc.push( - installAllPipelines({ - dataStream, - esClient, - logger, - paths: pipelinePaths, - installablePackage, - }) - ); - } - return acc; - }, []) - : []; - - if (topLevelPipelinePaths) { - pipelines.push( - installAllPipelines({ - dataStream: undefined, - esClient, - logger, - paths: topLevelPipelinePaths, - installablePackage, - }) - ); - } + install: async (esClient, logger) => { + const pipelines = dataStreams + ? dataStreams.reduce>>((acc, dataStream) => { + if (dataStream.ingest_pipeline) { + acc.push( + installAllPipelines({ + dataStream, + esClient, + logger, + paths: pipelinePaths, + installablePackage, + }) + ); + } + return acc; + }, []) + : []; + + if (topLevelPipelinePaths) { + pipelines.push( + installAllPipelines({ + dataStream: undefined, + esClient, + logger, + paths: topLevelPipelinePaths, + installablePackage, + }) + ); + } - await Promise.all(pipelines); - return esReferences; + await Promise.all(pipelines); + }, + }; }; export function rewriteIngestPipeline( diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts index 998d0f9fb1ae57e..3478da69bf7212c 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.test.ts @@ -4,30 +4,19 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { loggerMock } from '@kbn/logging-mocks'; - import { createAppContextStartContractMock } from '../../../../mocks'; import { appContextService } from '../../..'; import type { RegistryDataStream } from '../../../../types'; -import type { Field } from '../../fields/field'; -import { installTemplate } from './install'; +import { prepareTemplate } from './install'; -describe('EPM install', () => { +describe('EPM index template install', () => { beforeEach(async () => { appContextService.start(createAppContextStartContractMock()); }); - it('tests installPackage to use correct priority and index_patterns for data stream with dataset_is_prefix not set', async () => { - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - esClient.indices.getIndexTemplate.mockImplementation(() => - elasticsearchServiceMock.createSuccessTransportRequestPromise({ index_templates: [] }) - ); - - const fields: Field[] = []; + it('tests prepareTemplate to use correct priority and index_patterns for data stream with dataset_is_prefix not set', async () => { const dataStreamDatasetIsPrefixUnset = { type: 'metrics', dataset: 'package.dataset', @@ -43,29 +32,14 @@ describe('EPM install', () => { }; const templateIndexPatternDatasetIsPrefixUnset = 'metrics-package.dataset-*'; const templatePriorityDatasetIsPrefixUnset = 200; - await installTemplate({ - esClient, - logger: loggerMock.create(), - fields, - dataStream: dataStreamDatasetIsPrefixUnset, - packageVersion: pkg.version, - packageName: pkg.name, - }); - - const sentTemplate = ( - esClient.indices.putIndexTemplate.mock.calls[0][0] as estypes.IndicesPutIndexTemplateRequest - ).body; - - expect(sentTemplate).toBeDefined(); - expect(sentTemplate?.priority).toBe(templatePriorityDatasetIsPrefixUnset); - expect(sentTemplate?.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixUnset]); + const { + indexTemplate: { indexTemplate }, + } = prepareTemplate({ pkg, dataStream: dataStreamDatasetIsPrefixUnset }); + expect(indexTemplate.priority).toBe(templatePriorityDatasetIsPrefixUnset); + expect(indexTemplate.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixUnset]); }); - it('tests installPackage to use correct priority and index_patterns for data stream with dataset_is_prefix set to false', async () => { - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - esClient.indices.getIndexTemplate.mockResponse({ index_templates: [] }); - - const fields: Field[] = []; + it('tests prepareTemplate to use correct priority and index_patterns for data stream with dataset_is_prefix set to false', async () => { const dataStreamDatasetIsPrefixFalse = { type: 'metrics', dataset: 'package.dataset', @@ -82,29 +56,15 @@ describe('EPM install', () => { }; const templateIndexPatternDatasetIsPrefixFalse = 'metrics-package.dataset-*'; const templatePriorityDatasetIsPrefixFalse = 200; - await installTemplate({ - esClient, - logger: loggerMock.create(), - fields, - dataStream: dataStreamDatasetIsPrefixFalse, - packageVersion: pkg.version, - packageName: pkg.name, - }); - - const sentTemplate = ( - esClient.indices.putIndexTemplate.mock.calls[0][0] as estypes.IndicesPutIndexTemplateRequest - ).body; + const { + indexTemplate: { indexTemplate }, + } = prepareTemplate({ pkg, dataStream: dataStreamDatasetIsPrefixFalse }); - expect(sentTemplate).toBeDefined(); - expect(sentTemplate?.priority).toBe(templatePriorityDatasetIsPrefixFalse); - expect(sentTemplate?.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixFalse]); + expect(indexTemplate.priority).toBe(templatePriorityDatasetIsPrefixFalse); + expect(indexTemplate.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixFalse]); }); - it('tests installPackage to use correct priority and index_patterns for data stream with dataset_is_prefix set to true', async () => { - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - esClient.indices.getIndexTemplate.mockResponse({ index_templates: [] }); - - const fields: Field[] = []; + it('tests prepareTemplate to use correct priority and index_patterns for data stream with dataset_is_prefix set to true', async () => { const dataStreamDatasetIsPrefixTrue = { type: 'metrics', dataset: 'package.dataset', @@ -121,71 +81,11 @@ describe('EPM install', () => { }; const templateIndexPatternDatasetIsPrefixTrue = 'metrics-package.dataset.*-*'; const templatePriorityDatasetIsPrefixTrue = 150; - await installTemplate({ - esClient, - logger: loggerMock.create(), - fields, - dataStream: dataStreamDatasetIsPrefixTrue, - packageVersion: pkg.version, - packageName: pkg.name, - }); - - const sentTemplate = ( - esClient.indices.putIndexTemplate.mock.calls[0][0] as estypes.IndicesPutIndexTemplateRequest - ).body; - - expect(sentTemplate).toBeDefined(); - expect(sentTemplate?.priority).toBe(templatePriorityDatasetIsPrefixTrue); - expect(sentTemplate?.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixTrue]); - }); - - it('tests installPackage remove the aliases property if the property existed', async () => { - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - - esClient.indices.getIndexTemplate.mockResponse({ - index_templates: [ - { - name: 'metrics-package.dataset', - // @ts-expect-error not full interface - index_template: { - index_patterns: ['metrics-package.dataset-*'], - template: { aliases: {} }, - }, - }, - ], - }); - - const fields: Field[] = []; - const dataStreamDatasetIsPrefixUnset = { - type: 'metrics', - dataset: 'package.dataset', - title: 'test data stream', - release: 'experimental', - package: 'package', - path: 'path', - ingest_pipeline: 'default', - } as RegistryDataStream; - const pkg = { - name: 'package', - version: '0.0.1', - }; - const templateIndexPatternDatasetIsPrefixUnset = 'metrics-package.dataset-*'; - const templatePriorityDatasetIsPrefixUnset = 200; - await installTemplate({ - esClient, - logger: loggerMock.create(), - fields, - dataStream: dataStreamDatasetIsPrefixUnset, - packageVersion: pkg.version, - packageName: pkg.name, - }); + const { + indexTemplate: { indexTemplate }, + } = prepareTemplate({ pkg, dataStream: dataStreamDatasetIsPrefixTrue }); - const sentTemplate = ( - esClient.indices.putIndexTemplate.mock.calls[0][0] as estypes.IndicesPutIndexTemplateRequest - ).body; - expect(sentTemplate).toBeDefined(); - expect(sentTemplate?.template?.aliases).not.toBeDefined(); - expect(sentTemplate?.priority).toBe(templatePriorityDatasetIsPrefixUnset); - expect(sentTemplate?.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixUnset]); + expect(indexTemplate.priority).toBe(templatePriorityDatasetIsPrefixTrue); + expect(indexTemplate.index_patterns).toEqual([templateIndexPatternDatasetIsPrefixTrue]); }); }); diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts index 2d2e5b2ffea2a33..df6d9d84a08c5e3 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts @@ -7,7 +7,7 @@ import { merge } from 'lodash'; import Boom from '@hapi/boom'; -import type { ElasticsearchClient, Logger, SavedObjectsClientContract } from '@kbn/core/server'; +import type { ElasticsearchClient, Logger } from '@kbn/core/server'; import { ElasticsearchAssetType } from '../../../../types'; import type { @@ -20,13 +20,12 @@ import type { TemplateMapEntry, TemplateMap, EsAssetReference, + PackageInfo, } from '../../../../types'; import { loadFieldsFromYaml, processFields } from '../../fields/field'; -import type { Field } from '../../fields/field'; import { getPipelineNameForInstallation } from '../ingest_pipeline/install'; import { getAsset, getPathParts } from '../../archive'; -import { updateEsAssetReferences } from '../../packages/install'; import { FLEET_COMPONENT_TEMPLATES, PACKAGE_TEMPLATE_SUFFIX, @@ -47,65 +46,55 @@ import { buildDefaultSettings } from './default_settings'; const FLEET_COMPONENT_TEMPLATE_NAMES = FLEET_COMPONENT_TEMPLATES.map((tmpl) => tmpl.name); -export const installTemplates = async ( +export const prepareToInstallTemplates = ( installablePackage: InstallablePackage, - esClient: ElasticsearchClient, - logger: Logger, paths: string[], - savedObjectsClient: SavedObjectsClientContract, esReferences: EsAssetReference[] -): Promise<{ - installedTemplates: IndexTemplateEntry[]; - installedEsReferences: EsAssetReference[]; -}> => { - // install any pre-built index template assets, - // atm, this is only the base package's global index templates - // Install component templates first, as they are used by the index templates - await installPreBuiltComponentTemplates(paths, esClient, logger); - await installPreBuiltTemplates(paths, esClient, logger); - +): { + assetsToAdd: EsAssetReference[]; + assetsToRemove: EsAssetReference[]; + install: (esClient: ElasticsearchClient, logger: Logger) => Promise; +} => { // remove package installation's references to index templates - esReferences = await updateEsAssetReferences( - savedObjectsClient, - installablePackage.name, - esReferences, - { - assetsToRemove: esReferences.filter( - ({ type }) => - type === ElasticsearchAssetType.indexTemplate || - type === ElasticsearchAssetType.componentTemplate - ), - } + const assetsToRemove = esReferences.filter( + ({ type }) => + type === ElasticsearchAssetType.indexTemplate || + type === ElasticsearchAssetType.componentTemplate ); // build templates per data stream from yml files const dataStreams = installablePackage.data_streams; - if (!dataStreams) return { installedTemplates: [], installedEsReferences: esReferences }; - - const installedTemplatesNested = await Promise.all( - dataStreams.map((dataStream) => - installTemplateForDataStream({ - pkg: installablePackage, - esClient, - logger, - dataStream, - }) - ) - ); - const installedTemplates = installedTemplatesNested.flat(); + if (!dataStreams) return { assetsToAdd: [], assetsToRemove, install: () => Promise.resolve([]) }; - // get template refs to save - const installedIndexTemplateRefs = getAllTemplateRefs(installedTemplates); - - // add package installation's references to index templates - esReferences = await updateEsAssetReferences( - savedObjectsClient, - installablePackage.name, - esReferences, - { assetsToAdd: installedIndexTemplateRefs } + const templates = dataStreams.map((dataStream) => + prepareTemplate({ pkg: installablePackage, dataStream }) ); + const assetsToAdd = getAllTemplateRefs(templates.map((template) => template.indexTemplate)); + + return { + assetsToAdd, + assetsToRemove, + install: async (esClient, logger) => { + // install any pre-built index template assets, + // atm, this is only the base package's global index templates + // Install component templates first, as they are used by the index templates + await installPreBuiltComponentTemplates(paths, esClient, logger); + await installPreBuiltTemplates(paths, esClient, logger); + + await Promise.all( + templates.map((template) => + installComponentAndIndexTemplateForDataStream({ + esClient, + logger, + componentTemplates: template.componentTemplates, + indexTemplate: template.indexTemplate, + }) + ) + ); - return { installedTemplates, installedEsReferences: esReferences }; + return templates.map((template) => template.indexTemplate); + }, + }; }; const installPreBuiltTemplates = async ( @@ -187,31 +176,24 @@ const isComponentTemplate = (path: string) => { }; /** - * installTemplateForDataStream installs one template for each data stream + * installComponentAndIndexTemplateForDataStream installs one template for each data stream * * The template is currently loaded with the pkgkey-package-data_stream */ -export async function installTemplateForDataStream({ - pkg, +export async function installComponentAndIndexTemplateForDataStream({ esClient, logger, - dataStream, + componentTemplates, + indexTemplate, }: { - pkg: InstallablePackage; esClient: ElasticsearchClient; logger: Logger; - dataStream: RegistryDataStream; -}): Promise { - const fields = await loadFieldsFromYaml(pkg, dataStream.path); - return installTemplate({ - esClient, - logger, - fields, - dataStream, - packageVersion: pkg.version, - packageName: pkg.name, - }); + componentTemplates: TemplateMap; + indexTemplate: IndexTemplateEntry; +}) { + await installDataStreamComponentTemplates({ esClient, logger, componentTemplates }); + await installTemplate({ esClient, logger, template: indexTemplate }); } function putComponentTemplate( @@ -291,35 +273,18 @@ function buildComponentTemplates(params: { return templatesMap; } -async function installDataStreamComponentTemplates(params: { - mappings: IndexTemplateMappings; - templateName: string; - registryElasticsearch: RegistryElasticsearch | undefined; +async function installDataStreamComponentTemplates({ + esClient, + logger, + componentTemplates, +}: { esClient: ElasticsearchClient; logger: Logger; - packageName: string; - defaultSettings: IndexTemplate['template']['settings']; + componentTemplates: TemplateMap; }) { - const { - templateName, - registryElasticsearch, - esClient, - packageName, - defaultSettings, - logger, - mappings, - } = params; - const componentTemplates = buildComponentTemplates({ - mappings, - templateName, - registryElasticsearch, - packageName, - defaultSettings, - }); - const templateEntries = Object.entries(componentTemplates); // TODO: Check return values for errors await Promise.all( - templateEntries.map(async ([name, body]) => { + Object.entries(componentTemplates).map(async ([name, body]) => { if (isUserSettingsTemplate(name)) { try { // Attempt to create custom component templates, ignore if they already exist @@ -342,8 +307,6 @@ async function installDataStreamComponentTemplates(params: { } }) ); - - return { componentTemplateNames: Object.keys(componentTemplates) }; } export async function ensureDefaultComponentTemplates( @@ -387,21 +350,15 @@ export async function ensureComponentTemplate( return { isCreated: !existingTemplate }; } -export async function installTemplate({ - esClient, - logger, - fields, +export function prepareTemplate({ + pkg, dataStream, - packageVersion, - packageName, }: { - esClient: ElasticsearchClient; - logger: Logger; - fields: Field[]; + pkg: Pick; dataStream: RegistryDataStream; - packageVersion: string; - packageName: string; -}): Promise { +}): { componentTemplates: TemplateMap; indexTemplate: IndexTemplateEntry } { + const { name: packageName, version: packageVersion } = pkg; + const fields = loadFieldsFromYaml(pkg, dataStream.path); const validFields = processFields(fields); const mappings = generateMappings(validFields); const templateName = generateTemplateName(dataStream); @@ -425,40 +382,51 @@ export async function installTemplate({ ilmPolicy: dataStream.ilm_policy, }); - const { componentTemplateNames } = await installDataStreamComponentTemplates({ + const componentTemplates = buildComponentTemplates({ + defaultSettings, mappings, + packageName, templateName, registryElasticsearch: dataStream.elasticsearch, - esClient, - logger, - packageName, - defaultSettings, }); const template = getTemplate({ templateIndexPattern, pipelineName, packageName, - composedOfTemplates: componentTemplateNames, + composedOfTemplates: Object.keys(componentTemplates), templatePriority, hidden: dataStream.hidden, }); + return { + componentTemplates, + indexTemplate: { + templateName, + indexTemplate: template, + }, + }; +} + +async function installTemplate({ + esClient, + logger, + template, +}: { + esClient: ElasticsearchClient; + logger: Logger; + template: IndexTemplateEntry; +}) { // TODO: Check return values for errors const esClientParams = { - name: templateName, - body: template, + name: template.templateName, + body: template.indexTemplate, }; await retryTransientEsErrors( () => esClient.indices.putIndexTemplate(esClientParams, { ignore: [404] }), { logger } ); - - return { - templateName, - indexTemplate: template, - }; } export function getAllTemplateRefs(installedTemplates: IndexTemplateEntry[]) { diff --git a/x-pack/plugins/fleet/server/services/epm/fields/field.ts b/x-pack/plugins/fleet/server/services/epm/fields/field.ts index 3f1a8d8b2b7baab..0e00840b0c74ecf 100644 --- a/x-pack/plugins/fleet/server/services/epm/fields/field.ts +++ b/x-pack/plugins/fleet/server/services/epm/fields/field.ts @@ -261,12 +261,12 @@ const isFields = (path: string) => { * Gets all field files, optionally filtered by dataset, extracts .yml files, merges them together */ -export const loadFieldsFromYaml = async ( +export const loadFieldsFromYaml = ( pkg: Pick, datasetName?: string -): Promise => { +): Field[] => { // Fetch all field definition files - const fieldDefinitionFiles = await getAssetsData(pkg, isFields, datasetName); + const fieldDefinitionFiles = getAssetsData(pkg, isFields, datasetName); return fieldDefinitionFiles.reduce((acc, file) => { // Make sure it is defined as it is optional. Should never happen. if (file.buffer) { diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts index 1462cd61c4bd38d..b9582ce1cf148c7 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts @@ -267,6 +267,7 @@ export async function installKibanaSavedObjects({ overwrite: true, readStream: createListStream(toBeSavedObjects), createNewCopies: false, + refresh: false, }) ); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts index 24c324e6b7cd002..0124bff41736fab 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts @@ -22,10 +22,10 @@ import { import type { InstallablePackage, InstallSource, PackageAssetReference } from '../../../../common'; import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants'; import type { AssetReference, Installation, InstallType } from '../../../types'; -import { installTemplates } from '../elasticsearch/template/install'; +import { prepareToInstallTemplates } from '../elasticsearch/template/install'; import { removeLegacyTemplates } from '../elasticsearch/template/remove_legacy'; import { - installPipelines, + prepareToInstallPipelines, isTopLevelPipeline, deletePreviousPipelines, } from '../elasticsearch/ingest_pipeline'; @@ -39,7 +39,7 @@ import { saveArchiveEntries } from '../archive/storage'; import { ConcurrentInstallOperationError } from '../../../errors'; import { packagePolicyService } from '../..'; -import { createInstallation } from './install'; +import { createInstallation, updateEsAssetReferences } from './install'; import { withPackageSpan } from './utils'; // this is only exported for testing @@ -146,17 +146,45 @@ export async function _installPackage({ installMlModel(packageInfo, paths, esClient, savedObjectsClient, logger, esReferences) ); - // installs versionized pipelines without removing currently installed ones - esReferences = await withPackageSpan('Install ingest pipelines', () => - installPipelines(packageInfo, paths, esClient, savedObjectsClient, logger, esReferences) + /** + * In order to install assets in parallel, we need to split the preparation step from the installation step. This + * allows us to know which asset references are going to be installed so that we can save them on the packages + * SO before installation begins. In the case of a failure during installing any individual asset, we'll have the + * references necessary to remove any assets in that were successfully installed during the rollback phase. + * + * This split of prepare/install could be extended to all asset types. Besides performance, it also allows us to + * more easily write unit tests against the asset generation code without needing to mock ES responses. + */ + const preparedIngestPipelines = prepareToInstallPipelines(packageInfo, paths); + const preparedIndexTemplates = prepareToInstallTemplates(packageInfo, paths, esReferences); + + // Update the references for the templates and ingest pipelines together. Need to be done togther to avoid race + // conditions on updating the installed_es field at the same time + // These must be saved before we actually attempt to install the templates or pipelines so that we know what to + // cleanup in the case that a single asset fails to install. + esReferences = await updateEsAssetReferences( + savedObjectsClient, + packageInfo.name, + esReferences, + { + assetsToRemove: preparedIndexTemplates.assetsToRemove, + assetsToAdd: [ + ...preparedIngestPipelines.assetsToAdd, + ...preparedIndexTemplates.assetsToAdd, + ], + } ); - // install or update the templates referencing the newly installed pipelines - const { installedTemplates, installedEsReferences: esReferencesAfterTemplates } = - await withPackageSpan('Install index templates', () => - installTemplates(packageInfo, esClient, logger, paths, savedObjectsClient, esReferences) - ); - esReferences = esReferencesAfterTemplates; + // Install index templates and ingest pipelines in parallel since they typically take the longest + const [installedTemplates] = await Promise.all([ + withPackageSpan('Install index templates', () => + preparedIndexTemplates.install(esClient, logger) + ), + // installs versionized pipelines without removing currently installed ones + withPackageSpan('Install ingest pipelines', () => + preparedIngestPipelines.install(esClient, logger) + ), + ]); try { await removeLegacyTemplates({ packageInfo, esClient, logger }); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/assets.ts b/x-pack/plugins/fleet/server/services/epm/packages/assets.ts index 0621d05d21497b2..d67e76f90e551a0 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/assets.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/assets.ts @@ -51,11 +51,11 @@ export function getAssets( // ASK: Does getAssetsData need an installSource now? // if so, should it be an Installation vs InstallablePackage or add another argument? -export async function getAssetsData( +export function getAssetsData( packageInfo: Pick, filter = (path: string): boolean => true, datasetName?: string -): Promise { +): ArchiveEntry[] { // Gather all asset data const assets = getAssets(packageInfo, filter, datasetName); const entries: ArchiveEntry[] = assets.map((path) => { diff --git a/x-pack/plugins/fleet/server/telemetry/sender.ts b/x-pack/plugins/fleet/server/telemetry/sender.ts index a2f9bcafbcbb870..8c53fc850741f05 100644 --- a/x-pack/plugins/fleet/server/telemetry/sender.ts +++ b/x-pack/plugins/fleet/server/telemetry/sender.ts @@ -168,7 +168,7 @@ export class TelemetryEventsSender { const resp = await axios.post(telemetryUrl, ndjson, { headers: { 'Content-Type': 'application/x-ndjson', - 'X-Elastic-Cluster-ID': clusterUuid, + ...(clusterUuid ? { 'X-Elastic-Cluster-ID': clusterUuid } : undefined), 'X-Elastic-Stack-Version': clusterVersionNumber ? clusterVersionNumber : '7.16.0', }, }); diff --git a/x-pack/plugins/fleet/server/types/index.tsx b/x-pack/plugins/fleet/server/types/index.tsx index 6356ff8aa6cac5f..37dde581d4b8f0b 100644 --- a/x-pack/plugins/fleet/server/types/index.tsx +++ b/x-pack/plugins/fleet/server/types/index.tsx @@ -12,10 +12,6 @@ export type { AgentStatus, AgentType, AgentAction, - AgentPolicyAction, - BaseAgentActionSOAttributes, - AgentActionSOAttributes, - AgentPolicyActionSOAttributes, PackagePolicy, PackagePolicyInput, PackagePolicyInputStream, diff --git a/x-pack/plugins/fleet/server/types/rest_spec/agent.ts b/x-pack/plugins/fleet/server/types/rest_spec/agent.ts index ea11637119dc969..e080fe66f7e2c64 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/agent.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/agent.ts @@ -34,6 +34,12 @@ export const PostNewAgentActionRequestSchema = { }), }; +export const PostCancelActionRequestSchema = { + params: schema.object({ + actionId: schema.string(), + }), +}; + export const PostAgentUnenrollRequestSchema = { params: schema.object({ agentId: schema.string(), @@ -71,6 +77,7 @@ export const PostBulkAgentUpgradeRequestSchema = { source_uri: schema.maybe(schema.string()), version: schema.string(), force: schema.maybe(schema.boolean()), + rollout_duration_seconds: schema.maybe(schema.number({ min: 600 })), }), }; diff --git a/x-pack/plugins/graph/public/components/_search_bar.scss b/x-pack/plugins/graph/public/components/_search_bar.scss index 4b41dbc9bba0b26..c555c0af2d077ea 100644 --- a/x-pack/plugins/graph/public/components/_search_bar.scss +++ b/x-pack/plugins/graph/public/components/_search_bar.scss @@ -1,7 +1,12 @@ .gphSearchBar__datasourceButton { - height: 100% !important; + max-width: 320px; + + @include euiBreakpoint('xs', 's') { + width: 100%; + max-width: none; + } } .gphSearchBar__datasourceButtonTooltip { padding: 0; -} \ No newline at end of file +} diff --git a/x-pack/plugins/graph/public/components/search_bar.test.tsx b/x-pack/plugins/graph/public/components/search_bar.test.tsx index c05da957599c9a0..559ff33330eab22 100644 --- a/x-pack/plugins/graph/public/components/search_bar.test.tsx +++ b/x-pack/plugins/graph/public/components/search_bar.test.tsx @@ -7,7 +7,7 @@ import { mountWithIntl } from '@kbn/test-jest-helpers'; import { SearchBar, SearchBarProps, SearchBarComponent, SearchBarStateProps } from './search_bar'; -import React, { Component, ReactElement } from 'react'; +import React, { Component } from 'react'; import { DocLinksStart, HttpStart, @@ -203,9 +203,7 @@ describe('search_bar', () => { // pick the button component out of the tree because // it's part of a popover and thus not covered by enzyme - ( - instance.find(QueryStringInput).prop('prepend') as ReactElement - ).props.children.props.onClick(); + instance.find('[data-test-subj="graphDatasourceButton"]').first().simulate('click'); expect(openSourceModal).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/graph/public/components/search_bar.tsx b/x-pack/plugins/graph/public/components/search_bar.tsx index 762a2e87d2a5a86..046ed05977c7988 100644 --- a/x-pack/plugins/graph/public/components/search_bar.tsx +++ b/x-pack/plugins/graph/public/components/search_bar.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiButton, EuiToolTip } from '@elastic/eui'; import React, { useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; @@ -110,6 +110,47 @@ export function SearchBarComponent(props: SearchBarStateProps & SearchBarProps) }} > + + + { + confirmWipeWorkspace( + () => + openSourceModal({ overlays, savedObjects, uiSettings }, onIndexPatternSelected), + i18n.translate('xpack.graph.clearWorkspace.confirmText', { + defaultMessage: + 'If you change data sources, your current fields and vertices will be reset.', + }), + { + confirmButtonText: i18n.translate( + 'xpack.graph.clearWorkspace.confirmButtonLabel', + { + defaultMessage: 'Change data source', + } + ), + title: i18n.translate('xpack.graph.clearWorkspace.modalTitle', { + defaultMessage: 'Unsaved changes', + }), + } + ); + }} + > + {currentIndexPattern + ? currentIndexPattern.title + : // This branch will be shown if the user exits the + // initial picker modal + i18n.translate('xpack.graph.bar.pickSourceLabel', { + defaultMessage: 'Select a data source', + })} + + + - { - confirmWipeWorkspace( - () => - openSourceModal( - { overlays, savedObjects, uiSettings }, - onIndexPatternSelected - ), - i18n.translate('xpack.graph.clearWorkspace.confirmText', { - defaultMessage: - 'If you change data sources, your current fields and vertices will be reset.', - }), - { - confirmButtonText: i18n.translate( - 'xpack.graph.clearWorkspace.confirmButtonLabel', - { - defaultMessage: 'Change data source', - } - ), - title: i18n.translate('xpack.graph.clearWorkspace.modalTitle', { - defaultMessage: 'Unsaved changes', - }), - } - ); - }} - > - {currentIndexPattern - ? currentIndexPattern.title - : // This branch will be shown if the user exits the - // initial picker modal - i18n.translate('xpack.graph.bar.pickSourceLabel', { - defaultMessage: 'Select a data source', - })} - - - } onChange={setQuery} /> diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap index 802d684a8a261a4..6b54e1d3f43f503 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap +++ b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap @@ -95,779 +95,258 @@ Array [ `; exports[`extend index management ilm summary extension should return extension when index has lifecycle error 1`] = ` - - -

    - - Index lifecycle management - -

    -
    - , +
    , +
    - - +
    - - - - Index lifecycle error - - + illegal_argument_exception: setting [index.lifecycle.rollover_alias] for index [testy3] is empty or not defined
    - +
    , +
    , +
    +
    +
    - - + testy + + +
    + + Current action + +
    +
    + rollover +
    +
    + + Failed step + +
    +
    + check-rollover-ready +
    +
    - -
    - - -
    - -
    +
    - -
    - -
    - - Lifecycle policy - -
    -
    - -
    - - - testy - - -
    -
    - -
    - - Current action - -
    -
    - -
    - rollover -
    -
    - -
    - - Failed step - -
    -
    - -
    - check-rollover-ready -
    -
    -
    -
    -
    -
    - -
    + Current phase + + +
    + hot +
    +
    + + Current action time + +
    +
    + 2018-12-07 13:02:55 +
    +
    + + Phase definition + +
    +
    - -
    - -
    - - Current phase - -
    -
    - -
    - hot -
    -
    - -
    - - Current action time - -
    -
    - -
    - 2018-12-07 13:02:55 -
    -
    - -
    - - - Phase definition - - -
    -
    - -
    - - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="phaseExecutionPopover" - isOpen={false} - key="phaseExecutionPopover" - ownFocus={true} - panelPaddingSize="m" - > -
    -
    - - - -
    -
    -
    -
    -
    -
    -
    -
    -
    + Show definition + +
    +
    + +
    - - +
    , +] `; exports[`extend index management ilm summary extension should return extension when index has lifecycle policy 1`] = ` - - -

    - - Index lifecycle management - -

    -
    - , +
    , +
    - - -
    - -
    +
    - -
    - -
    - - Lifecycle policy - -
    -
    - -
    - - - testy - - -
    -
    - -
    - - Current action - -
    -
    - -
    - complete -
    -
    - -
    - - Failed step - -
    -
    - -
    - - -
    -
    -
    -
    -
    -
    - -
    + Lifecycle policy + + +
    - -
    - -
    - - Current phase - -
    -
    - -
    - new -
    -
    - -
    - - Current action time - -
    -
    - -
    - 2018-12-07 13:02:55 -
    -
    -
    -
    -
    -
    + testy + + +
    + + Current action + +
    +
    + complete +
    +
    + + Failed step + +
    +
    + - +
    +
    -
    - +
    +
    +
    + + Current phase + +
    +
    + new +
    +
    + + Current action time + +
    +
    + 2018-12-07 13:02:55 +
    +
    +
    +
    , +] `; exports[`extend index management remove lifecycle action extension should return extension when all indices have lifecycle policy 1`] = ` diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap index f4d7fc149a69488..8cbb4aa450c7c56 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap +++ b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap @@ -70,7 +70,7 @@ exports[`policy table shows empty state when there are no policies 1`] = ` class="euiTextColor euiTextColor--subdued" >
    -
    -
    -
    - Confirm License Upload -
    -
    -
    -
    -
    -
    -
    - Some functionality will be lost if you replace your TRIAL license with a BASIC license. Review the list of features below. -
    -
    -
      -
    • - Watcher will be disabled -
    • -
    -
    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    -
    - } - > - -
    -
    - - - - - -
    - -
    - -
    - - Confirm License Upload - -
    -
    -
    -
    - -
    -
    - -
    -
    - -
    - Some functionality will be lost if you replace your TRIAL license with a BASIC license. Review the list of features below. -
    -
    - -
    -
      -
    • - Watcher will be disabled -
    • -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    - - - - - - - - -
    -
    -
    -
    -
    -
    - - - - - -
    -

    - - Your license key is a JSON file with a signature attached. - -

    -

    - - - , - } - } - > - Uploading a license will replace your current - - license. - -

    -
    - - -
    - - -
    - - } - onChange={[Function]} - > - -
    -
    - - - -
    - -
    - - Select or drag your license file - -
    -
    -
    -
    -
    -
    - -
    - - -
    - - -
    - - - - -
    - - - - - -
    -
    -
    -
    -
    - -
    - + Select or drag your license file
    - - - - - - +
    +
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    `; exports[`UploadLicense should display an error when ES says license is expired 1`] = ` - - - - +
    +
    +

    + Your license key is a JSON file with a signature attached. +

    +

    + Uploading a license will replace your current + + license. +

    +
    +
    +
    + +
    - - +
    + `; exports[`UploadLicense should display an error when ES says license is invalid 1`] = ` - - - + Upload your license + +
    +
    +

    + Your license key is a JSON file with a signature attached. +

    +

    + Uploading a license will replace your current + + license. +

    +
    +
    +
    - - - + Please address the highlighted errors. + +
    +
    +
    +
      +
    • + The supplied license is not valid for this product. +
    • +
    +
    +
    +
    +
    +
    + +
    +
    - -
    - -

    - - Upload your license - -

    -
    - -
    - - -
    -

    - - Your license key is a JSON file with a signature attached. - -

    -

    - - - , - } - } - > - Uploading a license will replace your current - - license. - -

    -
    - - -
    - - -
    - - -
    -
    - - Please address the highlighted errors. - -
    - -
    - -
    -
      -
    • - The supplied license is not valid for this product. -
    • -
    -
    -
    -
    -
    -
    -
    -
    - - } - onChange={[Function]} - > - -
    -
    - - - -
    - -
    - - Select or drag your license file - -
    -
    -
    -
    -
    -
    - -
    - - -
    - - -
    - - - - -
    - - - - - -
    -
    -
    -
    -
    - -
    - + Select or drag your license file
    - - - - - - +
    +
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    `; exports[`UploadLicense should display an error when submitting invalid JSON 1`] = ` - - - - +
    +
    +

    + Your license key is a JSON file with a signature attached. +

    +

    + Uploading a license will replace your current + + license. +

    +
    +
    +
    + +
    - - +
    +
    - -
    - -

    - - Upload your license - -

    -
    - -
    - - -
    -

    - - Your license key is a JSON file with a signature attached. - -

    -

    - - - , - } - } - > - Uploading a license will replace your current - - license. - -

    -
    - - -
    - - -
    - - -
    -
    - - Please address the highlighted errors. - -
    - -
    - -
    -
      -
    • - Error encountered uploading license: Check your license file. -
    • -
    -
    -
    -
    -
    -
    -
    -
    - - } - onChange={[Function]} - > - -
    -
    - - - -
    - -
    - - Select or drag your license file - -
    -
    -
    -
    -
    -
    - -
    - - -
    - - -
    - - - - -
    - - - - - -
    -
    -
    -
    -
    - -
    - + Select or drag your license file
    - - - - - - +
    +
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    `; exports[`UploadLicense should display error when ES returns error 1`] = ` - - - + Upload your license + +
    +
    +

    + Your license key is a JSON file with a signature attached. +

    +

    + Uploading a license will replace your current + + license. +

    +
    +
    +
    - +
    + + Please address the highlighted errors. + +
    +
    +
    +
      +
    • + Error encountered uploading license: Can not upgrade to a production license unless TLS is configured or security is disabled +
    • +
    +
    +
    +
    +
    - - +
    +
    - -
    - -

    - - Upload your license - -

    -
    - -
    - - -
    -

    - - Your license key is a JSON file with a signature attached. - -

    -

    - - - , - } - } - > - Uploading a license will replace your current - - license. - -

    -
    - - -
    - - -
    - - -
    -
    - - Please address the highlighted errors. - -
    - -
    - -
    -
      -
    • - Error encountered uploading license: Can not upgrade to a production license unless TLS is configured or security is disabled -
    • -
    -
    -
    -
    -
    -
    -
    -
    - - } - onChange={[Function]} - > - -
    -
    - - - -
    - -
    - - Select or drag your license file - -
    -
    -
    -
    -
    -
    - -
    - - -
    - - -
    - - - - -
    - - - - - -
    -
    -
    -
    -
    - -
    - + Select or drag your license file
    - - - - - - +
    +
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    `; diff --git a/x-pack/plugins/license_management/__jest__/upload_license.test.tsx b/x-pack/plugins/license_management/__jest__/upload_license.test.tsx index eb38aab4470d865..c24c2bf6a9c6b6a 100644 --- a/x-pack/plugins/license_management/__jest__/upload_license.test.tsx +++ b/x-pack/plugins/license_management/__jest__/upload_license.test.tsx @@ -89,7 +89,7 @@ describe('UploadLicense', () => { const rendered = mountWithIntl(component); store.dispatch(uploadLicense('INVALID', 'trial')); rendered.update(); - expect(rendered).toMatchSnapshot(); + expect(rendered.render()).toMatchSnapshot(); }); it('should display an error when ES says license is invalid', async () => { @@ -98,7 +98,7 @@ describe('UploadLicense', () => { const invalidLicense = JSON.stringify({ license: { type: 'basic' } }); await uploadLicense(invalidLicense)(store.dispatch, null, thunkServices); rendered.update(); - expect(rendered).toMatchSnapshot(); + expect(rendered.render()).toMatchSnapshot(); }); it('should display an error when ES says license is expired', async () => { @@ -107,7 +107,7 @@ describe('UploadLicense', () => { const invalidLicense = JSON.stringify({ license: { type: 'basic' } }); await uploadLicense(invalidLicense)(store.dispatch, null, thunkServices); rendered.update(); - expect(rendered).toMatchSnapshot(); + expect(rendered.render()).toMatchSnapshot(); }); it('should display a modal when license requires acknowledgement', async () => { @@ -117,7 +117,7 @@ describe('UploadLicense', () => { }); await uploadLicense(unacknowledgedLicense, 'trial')(store.dispatch, null, thunkServices); const rendered = mountWithIntl(component); - expect(rendered).toMatchSnapshot(); + expect(rendered.render()).toMatchSnapshot(); }); it('should refresh xpack info and navigate to BASE_PATH when ES accepts new license', async () => { @@ -134,6 +134,6 @@ describe('UploadLicense', () => { const license = JSON.stringify({ license: { type: 'basic' } }); await uploadLicense(license)(store.dispatch, null, thunkServices); rendered.update(); - expect(rendered).toMatchSnapshot(); + expect(rendered.render()).toMatchSnapshot(); }); }); diff --git a/x-pack/plugins/licensing/common/register_analytics_context_provider.test.ts b/x-pack/plugins/licensing/common/register_analytics_context_provider.test.ts new file mode 100644 index 000000000000000..7edccfd319c91f1 --- /dev/null +++ b/x-pack/plugins/licensing/common/register_analytics_context_provider.test.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { firstValueFrom, ReplaySubject, Subject } from 'rxjs'; +import type { ILicense } from './types'; +import { registerAnalyticsContextProvider } from './register_analytics_context_provider'; + +describe('registerAnalyticsContextProvider', () => { + const analyticsClientMock = { + registerContextProvider: jest.fn(), + }; + + let license$: Subject; + + beforeEach(() => { + jest.clearAllMocks(); + license$ = new ReplaySubject(1); + registerAnalyticsContextProvider(analyticsClientMock, license$); + }); + + test('should register the analytics context provider', () => { + expect(analyticsClientMock.registerContextProvider).toHaveBeenCalledTimes(1); + }); + + test('emits a context value the moment license emits', async () => { + license$.next({ + uid: 'uid', + status: 'active', + isActive: true, + type: 'basic', + signature: 'signature', + isAvailable: true, + toJSON: jest.fn(), + getUnavailableReason: jest.fn(), + hasAtLeast: jest.fn(), + check: jest.fn(), + getFeature: jest.fn(), + }); + await expect( + firstValueFrom(analyticsClientMock.registerContextProvider.mock.calls[0][0].context$) + ).resolves.toEqual({ + license_id: 'uid', + license_status: 'active', + license_type: 'basic', + }); + }); +}); diff --git a/x-pack/plugins/licensing/common/register_analytics_context_provider.ts b/x-pack/plugins/licensing/common/register_analytics_context_provider.ts new file mode 100644 index 000000000000000..60f3fbbb3e6033c --- /dev/null +++ b/x-pack/plugins/licensing/common/register_analytics_context_provider.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Observable } from 'rxjs'; +import { map } from 'rxjs'; +import type { AnalyticsClient } from '@kbn/analytics-client'; +import type { ILicense } from './types'; + +export function registerAnalyticsContextProvider( + // Using `AnalyticsClient` from the package to be able to implement this method in the `common` dir. + analytics: Pick, + license$: Observable +) { + analytics.registerContextProvider({ + name: 'license info', + context$: license$.pipe( + map((license) => ({ + license_id: license.uid, + license_status: license.status, + license_type: license.type, + })) + ), + schema: { + license_id: { + type: 'keyword', + _meta: { description: 'The license ID', optional: true }, + }, + license_status: { + type: 'keyword', + _meta: { description: 'The license Status (active/invalid/expired)', optional: true }, + }, + license_type: { + type: 'keyword', + _meta: { + description: 'The license Type (basic/standard/gold/platinum/enterprise/trial)', + optional: true, + }, + }, + }, + }); +} diff --git a/x-pack/plugins/licensing/public/plugin.ts b/x-pack/plugins/licensing/public/plugin.ts index 9ef27e22657affa..3953a29a08214a0 100644 --- a/x-pack/plugins/licensing/public/plugin.ts +++ b/x-pack/plugins/licensing/public/plugin.ts @@ -15,6 +15,7 @@ import { License } from '../common/license'; import { mountExpiredBanner } from './expired_banner'; import { FeatureUsageService } from './services'; import type { PublicLicenseJSON } from '../common/types'; +import { registerAnalyticsContextProvider } from '../common/register_analytics_context_provider'; export const licensingSessionStorageKey = 'xpack.licensing'; @@ -82,6 +83,8 @@ export class LicensingPlugin implements Plugin { if (license.isAvailable) { this.prevSignature = license.signature; diff --git a/x-pack/plugins/licensing/server/plugin.ts b/x-pack/plugins/licensing/server/plugin.ts index 98dd1e7cbbb93ef..aaeeb4e05800848 100644 --- a/x-pack/plugins/licensing/server/plugin.ts +++ b/x-pack/plugins/licensing/server/plugin.ts @@ -21,6 +21,7 @@ import { IClusterClient, } from '@kbn/core/server'; +import { registerAnalyticsContextProvider } from '../common/register_analytics_context_provider'; import { ILicense, PublicLicense, @@ -120,6 +121,8 @@ export class LicensingPlugin implements Plugin = ({ const handleOperatorChange = useCallback( ([newOperator]: OperatorOption[]): void => { const { updatedEntry, index } = getEntryOnOperatorChange(entry, newOperator); - + handleError(false); onChange(updatedEntry, index); }, - [onChange, entry] + [onChange, entry, handleError] ); const handleFieldMatchValueChange = useCallback( diff --git a/x-pack/plugins/logstash/public/application/index.tsx b/x-pack/plugins/logstash/public/application/index.tsx index d4b4a39b6df9f85..03c2d9d878e2e11 100644 --- a/x-pack/plugins/logstash/public/application/index.tsx +++ b/x-pack/plugins/logstash/public/application/index.tsx @@ -25,8 +25,6 @@ import { import { PipelineList } from './components/pipeline_list'; import { PipelineEditView } from './pipeline_edit_view'; // @ts-ignore -import { Pipeline } from '../models/pipeline'; -// @ts-ignore import * as Breadcrumbs from './breadcrumbs'; export const renderApp = async ( diff --git a/x-pack/plugins/maps/common/execution_context.ts b/x-pack/plugins/maps/common/execution_context.ts index 4a11eb5d890295c..f62f1da85f99d35 100644 --- a/x-pack/plugins/maps/common/execution_context.ts +++ b/x-pack/plugins/maps/common/execution_context.ts @@ -6,9 +6,14 @@ */ import { isUndefined, omitBy } from 'lodash'; +import type { KibanaExecutionContext } from '@kbn/core/public'; import { APP_ID } from './constants'; -export function makeExecutionContext(context: { id?: string; url?: string; description?: string }) { +export function makeExecutionContext(context: { + id?: string; + url?: string; + description?: string; +}): KibanaExecutionContext { return omitBy( { name: APP_ID, diff --git a/x-pack/plugins/maps/common/mvt_request_body.ts b/x-pack/plugins/maps/common/mvt_request_body.ts index 16f9d2cce638177..e5517b23e0cba12 100644 --- a/x-pack/plugins/maps/common/mvt_request_body.ts +++ b/x-pack/plugins/maps/common/mvt_request_body.ts @@ -7,6 +7,7 @@ import type { RisonValue } from 'rison-node'; import rison from 'rison-node'; +import { RENDER_AS } from './constants'; export function decodeMvtResponseBody(encodedRequestBody: string): object { return rison.decode(decodeURIComponent(encodedRequestBody)) as object; @@ -15,3 +16,107 @@ export function decodeMvtResponseBody(encodedRequestBody: string): object { export function encodeMvtResponseBody(unencodedRequestBody: object): string { return encodeURIComponent(rison.encode(unencodedRequestBody as RisonValue)); } + +export function getAggsTileRequest({ + encodedRequestBody, + geometryFieldName, + gridPrecision, + index, + renderAs = RENDER_AS.POINT, + x, + y, + z, +}: { + encodedRequestBody: string; + geometryFieldName: string; + gridPrecision: number; + index: string; + renderAs: RENDER_AS; + x: number; + y: number; + z: number; +}) { + const requestBody = decodeMvtResponseBody(encodedRequestBody) as any; + return { + path: `/${encodeURIComponent(index)}/_mvt/${geometryFieldName}/${z}/${x}/${y}`, + body: { + size: 0, // no hits + grid_precision: gridPrecision, + exact_bounds: false, + extent: 4096, // full resolution, + query: requestBody.query, + grid_agg: renderAs === RENDER_AS.HEX ? 'geohex' : 'geotile', + grid_type: renderAs === RENDER_AS.GRID || renderAs === RENDER_AS.HEX ? 'grid' : 'centroid', + aggs: requestBody.aggs, + fields: requestBody.fields, + runtime_mappings: requestBody.runtime_mappings, + }, + }; +} + +export function getHitsTileRequest({ + encodedRequestBody, + geometryFieldName, + index, + x, + y, + z, +}: { + encodedRequestBody: string; + geometryFieldName: string; + index: string; + x: number; + y: number; + z: number; +}) { + const requestBody = decodeMvtResponseBody(encodedRequestBody) as any; + return { + path: `/${encodeURIComponent(index)}/_mvt/${geometryFieldName}/${z}/${x}/${y}`, + body: { + grid_precision: 0, // no aggs + exact_bounds: true, + extent: 4096, // full resolution, + query: requestBody.query, + fields: mergeFields( + [ + requestBody.docvalue_fields as Field[] | undefined, + requestBody.stored_fields as Field[] | undefined, + ], + [geometryFieldName] + ), + runtime_mappings: requestBody.runtime_mappings, + track_total_hits: typeof requestBody.size === 'number' ? requestBody.size + 1 : false, + }, + }; +} + +// can not use "import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey" +// SearchRequest is incorrectly typed and does not support Field as object +// https://github.com/elastic/elasticsearch-js/issues/1615 +type Field = + | string + | { + field: string; + format: string; + }; + +function mergeFields(fieldsList: Array, excludeNames: string[]): Field[] { + const fieldNames: string[] = []; + const mergedFields: Field[] = []; + + fieldsList.forEach((fields) => { + if (!fields) { + return; + } + + fields.forEach((field) => { + const fieldName = typeof field === 'string' ? field : field.field; + if (!excludeNames.includes(fieldName) && !fieldNames.includes(fieldName)) { + fieldNames.push(fieldName); + mergedFields.push(field); + } + }); + }); + + return mergedFields; +} diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json index 4c4ca64f7ac0753..5945ee3d35d8b61 100644 --- a/x-pack/plugins/maps/kibana.json +++ b/x-pack/plugins/maps/kibana.json @@ -33,6 +33,7 @@ "home", "savedObjectsTagging", "charts", + "screenshotMode", "security", "spaces", "usageCollection" diff --git a/x-pack/plugins/maps/public/actions/layer_actions.ts b/x-pack/plugins/maps/public/actions/layer_actions.ts index 25ba0ac862db865..5a1c37c11b80dd4 100644 --- a/x-pack/plugins/maps/public/actions/layer_actions.ts +++ b/x-pack/plugins/maps/public/actions/layer_actions.ts @@ -22,7 +22,7 @@ import { getSelectedLayerId, } from '../selectors/map_selectors'; import { FLYOUT_STATE } from '../reducers/ui'; -import { cancelRequest } from '../reducers/non_serializable_instances'; +import { cancelRequest, getInspectorAdapters } from '../reducers/non_serializable_instances'; import { setDrawMode, updateFlyout } from './ui_actions'; import { ADD_LAYER, @@ -451,6 +451,9 @@ function updateLayerType(layerId: string, newLayerType: string) { return; } dispatch(clearDataRequests(layer)); + if (layer.getSource().isESSource()) { + getInspectorAdapters(getState()).vectorTiles?.removeLayer(layerId); + } dispatch({ type: UPDATE_LAYER_PROP, id: layerId, @@ -587,6 +590,9 @@ function removeLayerFromLayerList(layerId: string) { }); dispatch(updateTooltipStateForLayer(layerGettingRemoved)); layerGettingRemoved.destroy(); + if (layerGettingRemoved.getSource().isESSource()) { + getInspectorAdapters(getState())?.vectorTiles.removeLayer(layerId); + } dispatch({ type: REMOVE_LAYER, id: layerId, diff --git a/x-pack/plugins/maps/public/actions/map_actions.test.ts b/x-pack/plugins/maps/public/actions/map_actions.test.ts index 935ca332baa22e6..407b7ef48ea5296 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.test.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.test.ts @@ -8,6 +8,7 @@ /* eslint @typescript-eslint/no-var-requires: 0 */ jest.mock('../selectors/map_selectors', () => ({})); +jest.mock('../reducers/non_serializable_instances', () => ({})); jest.mock('./data_request_actions', () => { return { syncDataForAllLayers: () => {}, @@ -25,6 +26,9 @@ import { mapExtentChanged, setMouseCoordinates, setQuery } from './map_actions'; const getStoreMock = jest.fn(); const dispatchMock = jest.fn(); +const vectorTileAdapterMock = { + setTiles: jest.fn(), +}; describe('map_actions', () => { afterEach(() => { @@ -43,6 +47,12 @@ describe('map_actions', () => { require('../selectors/map_selectors').getLayerList = () => { return []; }; + + require('../reducers/non_serializable_instances').getInspectorAdapters = () => { + return { + vectorTiles: vectorTileAdapterMock, + }; + }; }); it('should set buffer', () => { @@ -61,6 +71,8 @@ describe('map_actions', () => { }); action(dispatchMock, getStoreMock); + expect(vectorTileAdapterMock.setTiles.mock.calls[0]).toEqual([[{ x: 24, y: 15, z: 5 }]]); + expect(dispatchMock.mock.calls[0]).toEqual([ { mapViewContext: { @@ -101,6 +113,12 @@ describe('map_actions', () => { minLon: 92.5, }, }; + + require('../reducers/non_serializable_instances').getInspectorAdapters = () => { + return { + vectorTiles: vectorTileAdapterMock, + }; + }; }; }); @@ -120,6 +138,8 @@ describe('map_actions', () => { }); action(dispatchMock, getStoreMock); + expect(vectorTileAdapterMock.setTiles.mock.calls.length).toBe(0); + expect(dispatchMock.mock.calls[0]).toEqual([ { mapViewContext: { @@ -162,6 +182,8 @@ describe('map_actions', () => { }); action(dispatchMock, getStoreMock); + expect(vectorTileAdapterMock.setTiles.mock.calls.length).toBe(1); + expect(dispatchMock.mock.calls[0]).toEqual([ { mapViewContext: { @@ -204,6 +226,8 @@ describe('map_actions', () => { }); action(dispatchMock, getStoreMock); + expect(vectorTileAdapterMock.setTiles.mock.calls.length).toBe(1); + expect(dispatchMock.mock.calls[0]).toEqual([ { mapViewContext: { diff --git a/x-pack/plugins/maps/public/actions/map_actions.ts b/x-pack/plugins/maps/public/actions/map_actions.ts index e8585560238fd48..c23b77326f29340 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.ts @@ -17,6 +17,7 @@ import { Geometry, Position } from 'geojson'; import { asyncForEach, asyncMap } from '@kbn/std'; import { DRAW_MODE, DRAW_SHAPE, LAYER_STYLE_TYPE } from '../../common/constants'; import type { MapExtentState, MapViewContext } from '../reducers/map/types'; +import { getInspectorAdapters } from '../reducers/non_serializable_instances'; import { MapStoreState } from '../reducers/store'; import { IVectorStyle } from '../classes/styles/vector/vector_style'; import { @@ -73,7 +74,7 @@ import { INITIAL_LOCATION } from '../../common/constants'; import { updateTooltipStateForLayer } from './tooltip_actions'; import { isVectorLayer, IVectorLayer } from '../classes/layers/vector_layer'; import { SET_DRAW_MODE, pushDeletedFeatureId, clearDeletedFeatureIds } from './ui_actions'; -import { expandToTileBoundaries } from '../classes/util/geo_tile_utils'; +import { expandToTileBoundaries, getTilesForExtent } from '../classes/util/geo_tile_utils'; import { getToasts } from '../kibana_services'; import { getDeletedFeatureIds } from '../selectors/ui_selectors'; @@ -217,14 +218,18 @@ export function mapExtentChanged(mapExtentState: MapExtentState) { doesPrevBufferContainNextExtent = turfBooleanContains(bufferGeometry, extentGeometry); } + const requiresNewBuffer = + !prevBuffer || !doesPrevBufferContainNextExtent || prevZoom !== nextZoom; + if (requiresNewBuffer) { + getInspectorAdapters(getState()).vectorTiles.setTiles(getTilesForExtent(nextZoom, extent)); + } dispatch({ type: MAP_EXTENT_CHANGED, mapViewContext: { ...mapExtentState, - buffer: - !prevBuffer || !doesPrevBufferContainNextExtent || prevZoom !== nextZoom - ? expandToTileBoundaries(extent, Math.ceil(nextZoom)) - : prevBuffer, + buffer: requiresNewBuffer + ? expandToTileBoundaries(extent, Math.ceil(nextZoom)) + : prevBuffer, } as MapViewContext, }); diff --git a/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts b/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts index d1247590af2e95e..0906e39ed37fcf9 100644 --- a/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/heatmap_layer/heatmap_layer.ts @@ -94,6 +94,7 @@ export class HeatmapLayer extends AbstractLayer { async syncData(syncContext: DataRequestContext) { await syncMvtSourceData({ layerId: this.getId(), + layerName: await this.getDisplayName(), prevDataRequest: this.getSourceDataRequest(), requestMeta: buildVectorRequestMeta( this.getSource(), diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/pluck_style_meta_from_features.test.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/pluck_style_meta_from_features.test.ts index 5c609f66e3f539f..31aea302b70b17b 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/pluck_style_meta_from_features.test.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/pluck_style_meta_from_features.test.ts @@ -188,7 +188,7 @@ describe('pluckStyleMetaFromFeatures', () => { }); }); - test('Should extract scaled field range', async () => { + test('Should extract range', async () => { const features = [ { type: 'Feature', @@ -197,7 +197,7 @@ describe('pluckStyleMetaFromFeatures', () => { coordinates: [0, 0], }, properties: { - myDynamicField: 1, + myDynamicField: 3, }, }, { @@ -242,9 +242,9 @@ describe('pluckStyleMetaFromFeatures', () => { myDynamicField: { categories: [], range: { - delta: 9, + delta: 7, max: 10, - min: 1, + min: 3, }, }, }, @@ -255,6 +255,65 @@ describe('pluckStyleMetaFromFeatures', () => { }, }); }); + + test('Should extract range with "min = 1" for count field', async () => { + const features = [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [0, 0], + }, + properties: { + count: 3, + }, + }, + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [0, 0], + }, + properties: { + count: 10, + }, + }, + ] as Feature[]; + const dynamicColorOptions = { + type: COLOR_MAP_TYPE.ORDINAL, + field: { + origin: FIELD_ORIGIN.SOURCE, + name: 'count', + }, + } as ColorDynamicOptions; + const field = new InlineField({ + fieldName: dynamicColorOptions.field!.name, + source: {} as unknown as IVectorSource, + origin: dynamicColorOptions.field!.origin, + dataType: 'number', + }); + field.isCount = () => { + return true; + }; + const dynamicColorProperty = new DynamicColorProperty( + dynamicColorOptions, + VECTOR_STYLES.FILL_COLOR, + field, + {} as unknown as IVectorLayer, + () => { + return null; + } // getFieldFormatter + ); + + const styleMeta = await pluckStyleMetaFromFeatures(features, Object.values(VECTOR_SHAPE_TYPE), [ + dynamicColorProperty, + ]); + expect(styleMeta.fieldMeta.count.range).toEqual({ + delta: 9, + max: 10, + min: 1, + }); + }); }); describe('pluckCategoricalStyleMetaFromFeatures', () => { diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/pluck_style_meta_from_features.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/pluck_style_meta_from_features.ts index 2ea0fef1bf648f7..7867161a14e2159 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/pluck_style_meta_from_features.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/pluck_style_meta_from_features.ts @@ -113,18 +113,22 @@ function pluckOrdinalStyleMetaFromFeatures( property: IDynamicStyleProperty, features: Feature[] ): RangeFieldMeta | null { - if (!property.isOrdinal()) { + const field = property.getField(); + if (!field || !property.isOrdinal()) { return null; } const name = property.getFieldName(); - let min = Infinity; + const isCount = field.isCount(); + let min = isCount ? 1 : Infinity; let max = -Infinity; for (let i = 0; i < features.length; i++) { const feature = features[i]; const newValue = feature.properties ? parseFloat(feature.properties[name]) : NaN; if (!isNaN(newValue)) { - min = Math.min(min, newValue); + if (!isCount) { + min = Math.min(min, newValue); + } max = Math.max(max, newValue); } } diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.test.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.test.ts index ccc73f94aac5770..735d38f0f362404 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.test.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.test.ts @@ -42,6 +42,9 @@ const mockSource = { isGeoGridPrecisionAware: () => { return false; }, + isESSource: () => { + return false; + }, } as unknown as IMvtVectorSource; describe('syncMvtSourceData', () => { @@ -50,6 +53,7 @@ describe('syncMvtSourceData', () => { await syncMvtSourceData({ layerId: 'layer1', + layerName: 'my layer', prevDataRequest: undefined, requestMeta: { ...syncContext.dataFilters, @@ -96,6 +100,7 @@ describe('syncMvtSourceData', () => { await syncMvtSourceData({ layerId: 'layer1', + layerName: 'my layer', prevDataRequest: { getMeta: () => { return prevRequestMeta; @@ -138,6 +143,7 @@ describe('syncMvtSourceData', () => { await syncMvtSourceData({ layerId: 'layer1', + layerName: 'my layer', prevDataRequest: { getMeta: () => { return prevRequestMeta; @@ -177,6 +183,7 @@ describe('syncMvtSourceData', () => { await syncMvtSourceData({ layerId: 'layer1', + layerName: 'my layer', prevDataRequest: { getMeta: () => { return prevRequestMeta; @@ -224,6 +231,7 @@ describe('syncMvtSourceData', () => { await syncMvtSourceData({ layerId: 'layer1', + layerName: 'my layer', prevDataRequest: { getMeta: () => { return prevRequestMeta; @@ -263,6 +271,7 @@ describe('syncMvtSourceData', () => { await syncMvtSourceData({ layerId: 'layer1', + layerName: 'my layer', prevDataRequest: { getMeta: () => { return prevRequestMeta; @@ -302,6 +311,7 @@ describe('syncMvtSourceData', () => { await syncMvtSourceData({ layerId: 'layer1', + layerName: 'my layer', prevDataRequest: { getMeta: () => { return prevRequestMeta; @@ -325,4 +335,38 @@ describe('syncMvtSourceData', () => { // @ts-expect-error sinon.assert.calledOnce(syncContext.stopLoading); }); + + test('Should add layer to vector tile inspector when source is synced', async () => { + const syncContext = new MockSyncContext({ dataFilters: {} }); + const mockVectorTileAdapter = { + addLayer: sinon.spy(), + }; + + await syncMvtSourceData({ + layerId: 'layer1', + layerName: 'my layer', + prevDataRequest: undefined, + requestMeta: { + ...syncContext.dataFilters, + applyGlobalQuery: true, + applyGlobalTime: true, + applyForceRefresh: true, + fieldNames: [], + sourceMeta: {}, + isForceRefresh: false, + isFeatureEditorOpenForLayer: false, + }, + source: { + ...mockSource, + isESSource: () => { + return true; + }, + getInspectorAdapters: () => { + return { vectorTiles: mockVectorTileAdapter }; + }, + }, + syncContext, + }); + sinon.assert.calledOnce(mockVectorTileAdapter.addLayer); + }); }); diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.ts index f20ab0b5d200f91..daceeac1f072e2d 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_source_data.ts @@ -24,12 +24,14 @@ export interface MvtSourceData { export async function syncMvtSourceData({ layerId, + layerName, prevDataRequest, requestMeta, source, syncContext, }: { layerId: string; + layerName: string; prevDataRequest: DataRequest | undefined; requestMeta: VectorSourceRequestMeta; source: IMvtVectorSource; @@ -71,6 +73,9 @@ export async function syncMvtSourceData({ : prevData.refreshToken; const tileUrl = await source.getTileUrl(requestMeta, refreshToken); + if (source.isESSource()) { + source.getInspectorAdapters()?.vectorTiles.addLayer(layerId, layerName, tileUrl); + } const sourceData = { tileUrl, tileSourceLayer: source.getTileSourceLayer(), diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx index adb211f8f942001..462ea5b0cc8f120 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/mvt_vector_layer.tsx @@ -220,6 +220,7 @@ export class MvtVectorLayer extends AbstractVectorLayer { await syncMvtSourceData({ layerId: this.getId(), + layerName: await this.getDisplayName(), prevDataRequest: this.getSourceDataRequest(), requestMeta: await this._getVectorSourceRequestMeta( syncContext.isForceRefresh, diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/pluck_style_meta.test.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/pluck_style_meta.test.ts new file mode 100644 index 000000000000000..c0e624d412bc348 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/pluck_style_meta.test.ts @@ -0,0 +1,102 @@ +/* + * 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 { FIELD_ORIGIN } from '../../../../../common/constants'; +import { TileMetaFeature } from '../../../../../common/descriptor_types'; +import { pluckOrdinalStyleMeta } from './pluck_style_meta'; +import { IField } from '../../../fields/field'; +import { DynamicSizeProperty } from '../../../styles/vector/properties/dynamic_size_property'; + +describe('pluckOrdinalStyleMeta', () => { + test('should pluck range from metaFeatures', () => { + const mockField = { + isCount: () => { + return false; + }, + pluckRangeFromTileMetaFeature: (metaFeature: TileMetaFeature) => { + return { + max: metaFeature.properties['aggregations.avg_of_bytes.max'], + min: metaFeature.properties['aggregations.avg_of_bytes.min'], + }; + }, + } as unknown as IField; + const mockStyleProperty = { + getField: () => { + return mockField; + }, + isOrdinal: () => { + return true; + }, + getFieldOrigin: () => { + return FIELD_ORIGIN.SOURCE; + }, + } as unknown as DynamicSizeProperty; + const metaFeatures = [ + { + properties: { + 'aggregations.avg_of_bytes.max': 7565, + 'aggregations.avg_of_bytes.min': 1622, + }, + } as unknown as TileMetaFeature, + { + properties: { + 'aggregations.avg_of_bytes.max': 11869, + 'aggregations.avg_of_bytes.min': 659, + }, + } as unknown as TileMetaFeature, + ]; + expect(pluckOrdinalStyleMeta(mockStyleProperty, metaFeatures, undefined)).toEqual({ + max: 11869, + min: 659, + delta: 11210, + }); + }); + + test('should pluck range with min: 1 from metaFeatures for count field', () => { + const mockField = { + isCount: () => { + return true; + }, + pluckRangeFromTileMetaFeature: (metaFeature: TileMetaFeature) => { + return { + max: metaFeature.properties['aggregations._count.max'], + min: metaFeature.properties['aggregations._count.min'], + }; + }, + } as unknown as IField; + const mockStyleProperty = { + getField: () => { + return mockField; + }, + isOrdinal: () => { + return true; + }, + getFieldOrigin: () => { + return FIELD_ORIGIN.SOURCE; + }, + } as unknown as DynamicSizeProperty; + const metaFeatures = [ + { + properties: { + 'aggregations._count.max': 35, + 'aggregations._count.min': 3, + }, + } as unknown as TileMetaFeature, + { + properties: { + 'aggregations._count.max': 36, + 'aggregations._count.min': 5, + }, + } as unknown as TileMetaFeature, + ]; + expect(pluckOrdinalStyleMeta(mockStyleProperty, metaFeatures, undefined)).toEqual({ + max: 36, + min: 1, + delta: 35, + }); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/pluck_style_meta.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/pluck_style_meta.ts similarity index 92% rename from x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/pluck_style_meta.tsx rename to x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/pluck_style_meta.ts index 1f9784fb65dc059..564500b59742b6c 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/pluck_style_meta.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/pluck_style_meta.ts @@ -70,7 +70,7 @@ function pluckCategoricalStyleMeta( return []; } -function pluckOrdinalStyleMeta( +export function pluckOrdinalStyleMeta( property: IDynamicStyleProperty, metaFeatures: TileMetaFeature[], joinPropertiesMap: PropertiesMap | undefined @@ -80,13 +80,16 @@ function pluckOrdinalStyleMeta( return null; } - let min = Infinity; + const isCount = field.isCount(); + let min = isCount ? 1 : Infinity; let max = -Infinity; if (property.getFieldOrigin() === FIELD_ORIGIN.SOURCE) { for (let i = 0; i < metaFeatures.length; i++) { const range = field.pluckRangeFromTileMetaFeature(metaFeatures[i]); if (range) { - min = Math.min(range.min, min); + if (!isCount) { + min = Math.min(range.min, min); + } max = Math.max(range.max, max); } } @@ -94,7 +97,9 @@ function pluckOrdinalStyleMeta( joinPropertiesMap.forEach((value: { [key: string]: unknown }) => { const propertyValue = value[field.getName()]; if (typeof propertyValue === 'number') { - min = Math.min(propertyValue as number, min); + if (!isCount) { + min = Math.min(propertyValue as number, min); + } max = Math.max(propertyValue as number, max); } }); diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/util/__snapshots__/scaling_form.test.tsx.snap b/x-pack/plugins/maps/public/classes/sources/es_search_source/util/__snapshots__/scaling_form.test.tsx.snap index 513e3d4148efd57..f8c5951e95e0434 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/util/__snapshots__/scaling_form.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/util/__snapshots__/scaling_form.test.tsx.snap @@ -11,6 +11,7 @@ exports[`scaling form should disable clusters option when clustering is not supp id="xpack.maps.esSearch.scaleTitle" values={Object {}} /> + + { {this._renderModal()}
    - + {' '} { - private readonly _isSymbolizedAsIcon: boolean; - - constructor( - options: SizeDynamicOptions, - styleName: VECTOR_STYLES, - field: IField | null, - vectorLayer: IVectorLayer, - getFieldFormatter: (fieldName: string) => null | FieldFormatter, - isSymbolizedAsIcon: boolean - ) { - super(options, styleName, field, vectorLayer, getFieldFormatter); - this._isSymbolizedAsIcon = isSymbolizedAsIcon; - } - - supportsFeatureState() { - // mb style "icon-size" does not support feature state - if (this.getStyleName() === VECTOR_STYLES.ICON_SIZE && this._isSymbolizedAsIcon) { - return false; - } - - // mb style "text-size" does not support feature state - if (this.getStyleName() === VECTOR_STYLES.LABEL_SIZE) { - return false; - } - - return true; - } - - syncHaloWidthWithMb(mbLayerId: string, mbMap: MbMap) { - const haloWidth = this.getMbSizeExpression(); - mbMap.setPaintProperty(mbLayerId, 'icon-halo-width', haloWidth); - } - - syncIconSizeWithMb(symbolLayerId: string, mbMap: MbMap) { - const rangeFieldMeta = this.getRangeFieldMeta(); - if (this._isSizeDynamicConfigComplete() && rangeFieldMeta) { - const targetName = this.getMbFieldName(); - // Using property state instead of feature-state because layout properties do not support feature-state - mbMap.setLayoutProperty(symbolLayerId, 'icon-size', [ - 'interpolate', - ['linear'], - makeMbClampedNumberExpression({ - minValue: rangeFieldMeta.min, - maxValue: rangeFieldMeta.max, - fallback: 0, - lookupFunction: MB_LOOKUP_FUNCTION.GET, - fieldName: targetName, - }), - rangeFieldMeta.min, - this._options.minSize / HALF_MAKI_ICON_SIZE, - rangeFieldMeta.max, - this._options.maxSize / HALF_MAKI_ICON_SIZE, - ]); - } else { - mbMap.setLayoutProperty(symbolLayerId, 'icon-size', null); - } - } - - syncCircleStrokeWidthWithMb(mbLayerId: string, mbMap: MbMap) { - const lineWidth = this.getMbSizeExpression(); - mbMap.setPaintProperty(mbLayerId, 'circle-stroke-width', lineWidth); - } - - syncCircleRadiusWithMb(mbLayerId: string, mbMap: MbMap) { - const circleRadius = this.getMbSizeExpression(); - mbMap.setPaintProperty(mbLayerId, 'circle-radius', circleRadius); - } - - syncLineWidthWithMb(mbLayerId: string, mbMap: MbMap) { - const lineWidth = this.getMbSizeExpression(); - mbMap.setPaintProperty(mbLayerId, 'line-width', lineWidth); - } - - syncLabelSizeWithMb(mbLayerId: string, mbMap: MbMap) { - const lineWidth = this.getMbSizeExpression(); - mbMap.setLayoutProperty(mbLayerId, 'text-size', lineWidth); - } - - getMbSizeExpression() { - const rangeFieldMeta = this.getRangeFieldMeta(); - if (!this._isSizeDynamicConfigComplete() || !rangeFieldMeta) { - // return min of size to avoid flashing - // returning minimum allows "growing" of the symbols when the meta comes in - // A grow effect us less visually jarring as shrinking. - // especially relevant when displaying fine-grained grids using mvt - return this._options.minSize >= 0 ? this._options.minSize : null; - } - - return this._getMbDataDrivenSize({ - targetName: this.getMbFieldName(), - minSize: this._options.minSize, - maxSize: this._options.maxSize, - minValue: rangeFieldMeta.min, - maxValue: rangeFieldMeta.max, - }); - } - - _getMbDataDrivenSize({ - targetName, - minSize, - maxSize, - minValue, - maxValue, - }: { - targetName: string; - minSize: number; - maxSize: number; - minValue: number; - maxValue: number; - }) { - const stops = - minValue === maxValue ? [maxValue, maxSize] : [minValue, minSize, maxValue, maxSize]; - return [ - 'interpolate', - ['linear'], - makeMbClampedNumberExpression({ - lookupFunction: this.getMbLookupFunction(), - maxValue, - minValue, - fieldName: targetName, - fallback: 0, - }), - ...stops, - ]; - } - - _isSizeDynamicConfigComplete() { - return ( - this._field && - this._field.isValid() && - this._options.minSize >= 0 && - this._options.maxSize >= 0 - ); - } - - renderLegendDetailRow() { - return ; - } -} diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_size_property.test.tsx.snap b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/__snapshots__/dynamic_size_property.test.tsx.snap similarity index 100% rename from x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_size_property.test.tsx.snap rename to x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/__snapshots__/dynamic_size_property.test.tsx.snap diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.test.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.test.tsx new file mode 100644 index 000000000000000..0446b9e30f47b73 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.test.tsx @@ -0,0 +1,134 @@ +/* + * 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. + */ + +jest.mock('../../components/vector_style_editor', () => ({ + VectorStyleEditor: () => { + return
    mockVectorStyleEditor
    ; + }, +})); + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { DynamicSizeProperty } from './dynamic_size_property'; +import { FIELD_ORIGIN, RawValue, VECTOR_STYLES } from '../../../../../../common/constants'; +import { IField } from '../../../../fields/field'; +import { IVectorLayer } from '../../../../layers/vector_layer'; + +describe('renderLegendDetailRow', () => { + test('Should render as range', async () => { + const field = { + getLabel: async () => { + return 'foobar_label'; + }, + getName: () => { + return 'foodbar'; + }, + getOrigin: () => { + return FIELD_ORIGIN.SOURCE; + }, + supportsFieldMetaFromEs: () => { + return true; + }, + supportsFieldMetaFromLocalData: () => { + return true; + }, + } as unknown as IField; + const sizeProp = new DynamicSizeProperty( + { minSize: 0, maxSize: 10, fieldMetaOptions: { isEnabled: true } }, + VECTOR_STYLES.ICON_SIZE, + field, + {} as unknown as IVectorLayer, + () => { + return (value: RawValue) => value + '_format'; + }, + false + ); + sizeProp.getRangeFieldMeta = () => { + return { + min: 0, + max: 100, + delta: 100, + }; + }; + + const legendRow = sizeProp.renderLegendDetailRow(); + const component = shallow(legendRow); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + expect(component).toMatchSnapshot(); + }); +}); + +describe('getMbSizeExpression', () => { + test('Should return interpolation expression with single stop when range.delta is 0', async () => { + const field = { + isValid: () => { + return true; + }, + getName: () => { + return 'foobar'; + }, + getMbFieldName: () => { + return 'foobar'; + }, + getOrigin: () => { + return FIELD_ORIGIN.SOURCE; + }, + getSource: () => { + return { + isMvt: () => { + return false; + }, + }; + }, + supportsFieldMetaFromEs: () => { + return true; + }, + } as unknown as IField; + const sizeProp = new DynamicSizeProperty( + { minSize: 8, maxSize: 32, fieldMetaOptions: { isEnabled: true } }, + VECTOR_STYLES.ICON_SIZE, + field, + {} as unknown as IVectorLayer, + () => { + return (value: RawValue) => value + '_format'; + }, + false + ); + sizeProp.getRangeFieldMeta = () => { + return { + min: 100, + max: 100, + delta: 0, + }; + }; + + expect(sizeProp.getMbSizeExpression()).toEqual([ + 'interpolate', + ['linear'], + [ + 'sqrt', + [ + 'coalesce', + [ + 'case', + ['==', ['feature-state', 'foobar'], null], + 100, + ['max', ['min', ['to-number', ['feature-state', 'foobar']], 100], 100], + ], + 100, + ], + ], + 10, + 32, + ]); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.tsx new file mode 100644 index 000000000000000..d8fe8463edba862 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/dynamic_size_property.tsx @@ -0,0 +1,146 @@ +/* + * 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 React from 'react'; +import type { Map as MbMap } from '@kbn/mapbox-gl'; +import { DynamicStyleProperty } from '../dynamic_style_property'; +import { OrdinalLegend } from '../../components/legend/ordinal_legend'; +import { makeMbClampedNumberExpression } from '../../style_util'; +import { + FieldFormatter, + HALF_MAKI_ICON_SIZE, + VECTOR_STYLES, +} from '../../../../../../common/constants'; +import type { SizeDynamicOptions } from '../../../../../../common/descriptor_types'; +import type { IField } from '../../../../fields/field'; +import type { IVectorLayer } from '../../../../layers/vector_layer'; + +export class DynamicSizeProperty extends DynamicStyleProperty { + private readonly _isSymbolizedAsIcon: boolean; + + constructor( + options: SizeDynamicOptions, + styleName: VECTOR_STYLES, + field: IField | null, + vectorLayer: IVectorLayer, + getFieldFormatter: (fieldName: string) => null | FieldFormatter, + isSymbolizedAsIcon: boolean + ) { + super(options, styleName, field, vectorLayer, getFieldFormatter); + this._isSymbolizedAsIcon = isSymbolizedAsIcon; + } + + supportsFeatureState() { + // mb style "icon-size" does not support feature state + if (this.getStyleName() === VECTOR_STYLES.ICON_SIZE && this._isSymbolizedAsIcon) { + return false; + } + + // mb style "text-size" does not support feature state + if (this.getStyleName() === VECTOR_STYLES.LABEL_SIZE) { + return false; + } + + return true; + } + + syncHaloWidthWithMb(mbLayerId: string, mbMap: MbMap) { + mbMap.setPaintProperty(mbLayerId, 'icon-halo-width', this.getMbSizeExpression()); + } + + syncIconSizeWithMb(symbolLayerId: string, mbMap: MbMap) { + mbMap.setLayoutProperty(symbolLayerId, 'icon-size', this.getMbSizeExpression()); + } + + syncCircleStrokeWidthWithMb(mbLayerId: string, mbMap: MbMap) { + mbMap.setPaintProperty(mbLayerId, 'circle-stroke-width', this.getMbSizeExpression()); + } + + syncCircleRadiusWithMb(mbLayerId: string, mbMap: MbMap) { + mbMap.setPaintProperty(mbLayerId, 'circle-radius', this.getMbSizeExpression()); + } + + syncLineWidthWithMb(mbLayerId: string, mbMap: MbMap) { + mbMap.setPaintProperty(mbLayerId, 'line-width', this.getMbSizeExpression()); + } + + syncLabelSizeWithMb(mbLayerId: string, mbMap: MbMap) { + mbMap.setLayoutProperty(mbLayerId, 'text-size', this.getMbSizeExpression()); + } + + /* + * Returns interpolation expression linearly translating domain values [minValue, maxValue] to display range [minSize, maxSize] + */ + getMbSizeExpression() { + const rangeFieldMeta = this.getRangeFieldMeta(); + if (!this._isSizeDynamicConfigComplete() || !rangeFieldMeta) { + // return min of size to avoid flashing + // returning minimum allows "growing" of the symbols when the meta comes in + // A grow effect us less visually jarring as shrinking. + // especially relevant when displaying fine-grained grids using mvt + return this._options.minSize >= 0 ? this._options.minSize : null; + } + + const isArea = this.getStyleName() === VECTOR_STYLES.ICON_SIZE; + // isArea === true + // It's a mistake to linearly map a data value to an area dimension (i.e. cirle radius). + // Area squares area dimension ("pie * r * r" or "x * x"), visually distorting proportions. + // Since it is the quadratic function that is causing this, + // we need to counteract its effects by applying its inverse function — the square-root function. + // https://bl.ocks.org/guilhermesimoes/e6356aa90a16163a6f917f53600a2b4a + + // can not take square root of 0 or negative number + // shift values to be positive integers >= 1 + const valueShift = rangeFieldMeta.min < 1 ? Math.abs(rangeFieldMeta.min) + 1 : 0; + + const maxValueStopInput = isArea + ? Math.sqrt(rangeFieldMeta.max + valueShift) + : rangeFieldMeta.max; + const minValueStopInput = isArea + ? Math.sqrt(rangeFieldMeta.min + valueShift) + : rangeFieldMeta.min; + const maxRangeStopOutput = + this.getStyleName() === VECTOR_STYLES.ICON_SIZE && this._isSymbolizedAsIcon + ? this._options.maxSize / HALF_MAKI_ICON_SIZE + : this._options.maxSize; + const minRangeStopOutput = + this.getStyleName() === VECTOR_STYLES.ICON_SIZE && this._isSymbolizedAsIcon + ? this._options.minSize / HALF_MAKI_ICON_SIZE + : this._options.minSize; + const stops = + rangeFieldMeta.min === rangeFieldMeta.max + ? [maxValueStopInput, maxRangeStopOutput] + : [minValueStopInput, minRangeStopOutput, maxValueStopInput, maxRangeStopOutput]; + + const valueExpression = makeMbClampedNumberExpression({ + lookupFunction: this.getMbLookupFunction(), + maxValue: rangeFieldMeta.max, + minValue: rangeFieldMeta.min, + fieldName: this.getMbFieldName(), + fallback: rangeFieldMeta.min, + }); + const valueShiftExpression = + rangeFieldMeta.min < 1 ? ['+', valueExpression, valueShift] : valueExpression; + const sqrtValueExpression = ['sqrt', valueShiftExpression]; + const inputExpression = isArea ? sqrtValueExpression : valueExpression; + + return ['interpolate', ['linear'], inputExpression, ...stops]; + } + + _isSizeDynamicConfigComplete() { + return ( + this._field && + this._field.isValid() && + this._options.minSize >= 0 && + this._options.maxSize >= 0 + ); + } + + renderLegendDetailRow() { + return ; + } +} diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property.test.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/icon_size.test.ts similarity index 54% rename from x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property.test.tsx rename to x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/icon_size.test.ts index 0681d12007c4ab4..3144f4169ec8716 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property.test.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/icon_size.test.ts @@ -5,57 +5,39 @@ * 2.0. */ -jest.mock('../components/vector_style_editor', () => ({ - VectorStyleEditor: () => { - return
    mockVectorStyleEditor
    ; - }, -})); - -import React from 'react'; -import { shallow } from 'enzyme'; - import { DynamicSizeProperty } from './dynamic_size_property'; -import { FIELD_ORIGIN, RawValue, VECTOR_STYLES } from '../../../../../common/constants'; -import { IField } from '../../../fields/field'; -import type { Map as MbMap } from '@kbn/mapbox-gl'; -import { IVectorLayer } from '../../../layers/vector_layer'; - -export class MockMbMap { - _paintPropertyCalls: unknown[]; - - constructor() { - this._paintPropertyCalls = []; - } - setPaintProperty(...args: unknown[]) { - this._paintPropertyCalls.push([...args]); - } +import { FIELD_ORIGIN, RawValue, VECTOR_STYLES } from '../../../../../../common/constants'; +import { IField } from '../../../../fields/field'; +import { IVectorLayer } from '../../../../layers/vector_layer'; - getPaintPropertyCalls(): unknown[] { - return this._paintPropertyCalls; - } -} - -describe('renderLegendDetailRow', () => { - test('Should render as range', async () => { +describe('getMbSizeExpression - circle', () => { + test('Should return interpolation expression with square-root function', async () => { const field = { - getLabel: async () => { - return 'foobar_label'; + isValid: () => { + return true; }, getName: () => { return 'foodbar'; }, + getMbFieldName: () => { + return 'foobar'; + }, getOrigin: () => { return FIELD_ORIGIN.SOURCE; }, - supportsFieldMetaFromEs: () => { - return true; + getSource: () => { + return { + isMvt: () => { + return false; + }, + }; }, - supportsFieldMetaFromLocalData: () => { + supportsFieldMetaFromEs: () => { return true; }, } as unknown as IField; - const sizeProp = new DynamicSizeProperty( - { minSize: 0, maxSize: 10, fieldMetaOptions: { isEnabled: true } }, + const iconSize = new DynamicSizeProperty( + { minSize: 8, maxSize: 32, fieldMetaOptions: { isEnabled: true } }, VECTOR_STYLES.ICON_SIZE, field, {} as unknown as IVectorLayer, @@ -64,7 +46,7 @@ describe('renderLegendDetailRow', () => { }, false ); - sizeProp.getRangeFieldMeta = () => { + iconSize.getRangeFieldMeta = () => { return { min: 0, max: 100, @@ -72,19 +54,34 @@ describe('renderLegendDetailRow', () => { }; }; - const legendRow = sizeProp.renderLegendDetailRow(); - const component = shallow(legendRow); - - // Ensure all promises resolve - await new Promise((resolve) => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - expect(component).toMatchSnapshot(); + expect(iconSize.getMbSizeExpression()).toEqual([ + 'interpolate', + ['linear'], + [ + 'sqrt', + [ + '+', + [ + 'coalesce', + [ + 'case', + ['==', ['feature-state', 'foobar'], null], + 0, + ['max', ['min', ['to-number', ['feature-state', 'foobar']], 100], 0], + ], + 0, + ], + 1, + ], + ], + 1, + 8, + 10.04987562112089, + 32, + ]); }); -}); -describe('syncSize', () => { - test('Should sync with circle-radius prop', async () => { + test('Should return interpolation expression without value shift when range.min is > 1', async () => { const field = { isValid: () => { return true; @@ -109,7 +106,7 @@ describe('syncSize', () => { return true; }, } as unknown as IField; - const sizeProp = new DynamicSizeProperty( + const iconSize = new DynamicSizeProperty( { minSize: 8, maxSize: 32, fieldMetaOptions: { isEnabled: true } }, VECTOR_STYLES.ICON_SIZE, field, @@ -119,51 +116,46 @@ describe('syncSize', () => { }, false ); - sizeProp.getRangeFieldMeta = () => { + iconSize.getRangeFieldMeta = () => { return { - min: 0, + min: 1, max: 100, delta: 100, }; }; - const mockMbMap = new MockMbMap() as unknown as MbMap; - sizeProp.syncCircleRadiusWithMb('foobar', mockMbMap); - - // @ts-expect-error - expect(mockMbMap.getPaintPropertyCalls()).toEqual([ + expect(iconSize.getMbSizeExpression()).toEqual([ + 'interpolate', + ['linear'], [ - 'foobar', - 'circle-radius', + 'sqrt', [ - 'interpolate', - ['linear'], + 'coalesce', [ - 'coalesce', - [ - 'case', - ['==', ['feature-state', 'foobar'], null], - -1, - ['max', ['min', ['to-number', ['feature-state', 'foobar']], 100], 0], - ], - 0, + 'case', + ['==', ['feature-state', 'foobar'], null], + 1, + ['max', ['min', ['to-number', ['feature-state', 'foobar']], 100], 1], ], - 0, - 8, - 100, - 32, + 1, ], ], + 1, + 8, + 10, + 32, ]); }); +}); - test('Should truncate interpolate expression to max when no delta', async () => { +describe('getMbSizeExpression - icon', () => { + test('Should return interpolation expression with square-root function', async () => { const field = { isValid: () => { return true; }, getName: () => { - return 'foobar'; + return 'foodbar'; }, getMbFieldName: () => { return 'foobar'; @@ -182,7 +174,7 @@ describe('syncSize', () => { return true; }, } as unknown as IField; - const sizeProp = new DynamicSizeProperty( + const iconSize = new DynamicSizeProperty( { minSize: 8, maxSize: 32, fieldMetaOptions: { isEnabled: true } }, VECTOR_STYLES.ICON_SIZE, field, @@ -190,41 +182,40 @@ describe('syncSize', () => { () => { return (value: RawValue) => value + '_format'; }, - false + true ); - sizeProp.getRangeFieldMeta = () => { + iconSize.getRangeFieldMeta = () => { return { - min: 100, + min: 0, max: 100, - delta: 0, + delta: 100, }; }; - const mockMbMap = new MockMbMap() as unknown as MbMap; - - sizeProp.syncCircleRadiusWithMb('foobar', mockMbMap); - // @ts-expect-error - expect(mockMbMap.getPaintPropertyCalls()).toEqual([ + expect(iconSize.getMbSizeExpression()).toEqual([ + 'interpolate', + ['linear'], [ - 'foobar', - 'circle-radius', + 'sqrt', [ - 'interpolate', - ['linear'], + '+', [ 'coalesce', [ 'case', - ['==', ['feature-state', 'foobar'], null], - 99, - ['max', ['min', ['to-number', ['feature-state', 'foobar']], 100], 100], + ['==', ['get', 'foobar'], null], + 0, + ['max', ['min', ['to-number', ['get', 'foobar']], 100], 0], ], 0, ], - 100, - 32, + 1, ], ], + 1, + 1, + 10.04987562112089, + 4, ]); }); }); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/index.ts b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/index.ts new file mode 100644 index 000000000000000..bd324fc2da10f2d --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { DynamicSizeProperty } from './dynamic_size_property'; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/line_width.test.ts b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/line_width.test.ts new file mode 100644 index 000000000000000..f7453617d9609d1 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_size_property/line_width.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { DynamicSizeProperty } from './dynamic_size_property'; +import { FIELD_ORIGIN, RawValue, VECTOR_STYLES } from '../../../../../../common/constants'; +import { IField } from '../../../../fields/field'; +import { IVectorLayer } from '../../../../layers/vector_layer'; + +describe('getMbSizeExpression', () => { + test('Should return interpolation expression', async () => { + const field = { + isValid: () => { + return true; + }, + getName: () => { + return 'foodbar'; + }, + getMbFieldName: () => { + return 'foobar'; + }, + getOrigin: () => { + return FIELD_ORIGIN.SOURCE; + }, + getSource: () => { + return { + isMvt: () => { + return false; + }, + }; + }, + supportsFieldMetaFromEs: () => { + return true; + }, + } as unknown as IField; + const lineWidth = new DynamicSizeProperty( + { minSize: 8, maxSize: 32, fieldMetaOptions: { isEnabled: true } }, + VECTOR_STYLES.LINE_WIDTH, + field, + {} as unknown as IVectorLayer, + () => { + return (value: RawValue) => value + '_format'; + }, + false + ); + lineWidth.getRangeFieldMeta = () => { + return { + min: 0, + max: 100, + delta: 100, + }; + }; + + expect(lineWidth.getMbSizeExpression()).toEqual([ + 'interpolate', + ['linear'], + [ + 'coalesce', + [ + 'case', + ['==', ['feature-state', 'foobar'], null], + 0, + ['max', ['min', ['to-number', ['feature-state', 'foobar']], 100], 0], + ], + 0, + ], + 0, + 8, + 100, + 32, + ]); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts b/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts index ec2ecadf70fe3fc..5d4d5bc3ecbfb61 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/style_util.ts @@ -87,7 +87,7 @@ export function makeMbClampedNumberExpression({ [ 'case', ['==', [lookupFunction, fieldName], null], - minValue - 1, // == does a JS-y like check where returns true for null and undefined + fallback, // == does a JS-y like check where returns true for null and undefined clamp, ], fallback, diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx index 395f8b6391cad6e..d9a296031b5a166 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import _ from 'lodash'; import React, { CSSProperties, ReactElement } from 'react'; import { FeatureIdentifier, Map as MbMap } from '@kbn/mapbox-gl'; import { FeatureCollection } from 'geojson'; @@ -27,7 +26,7 @@ import { } from '../../../../common/constants'; import { StyleMeta } from './style_meta'; // @ts-expect-error -import { getMakiSymbol, PREFERRED_ICONS } from './symbol_utils'; +import { getMakiSymbol } from './symbol_utils'; import { VectorIcon } from './components/legend/vector_icon'; import { VectorStyleLegend } from './components/legend/vector_style_legend'; import { getHasLabel } from './style_util'; diff --git a/x-pack/plugins/maps/public/classes/util/geo_tile_utils.test.ts b/x-pack/plugins/maps/public/classes/util/geo_tile_utils.test.ts index 656ded611a11509..181907ac0e4c3ed 100644 --- a/x-pack/plugins/maps/public/classes/util/geo_tile_utils.test.ts +++ b/x-pack/plugins/maps/public/classes/util/geo_tile_utils.test.ts @@ -9,10 +9,11 @@ import { getTileKey, parseTileKey, getTileBoundingBox, + getTilesForExtent, expandToTileBoundaries, } from './geo_tile_utils'; -it('Should parse tile key', () => { +test('Should parse tile key', () => { expect(parseTileKey('15/23423/1867')).toEqual({ zoom: 15, x: 23423, @@ -21,11 +22,76 @@ it('Should parse tile key', () => { }); }); -it('Should get tile key', () => { +test('Should get tiles for extent', () => { + const extent = { + minLon: -132.19235, + minLat: 12.05834, + maxLon: -83.6593, + maxLat: 30.03121, + }; + + expect(getTilesForExtent(4.74, extent)).toEqual([ + { x: 2, y: 6, z: 4 }, + { x: 2, y: 7, z: 4 }, + { x: 3, y: 6, z: 4 }, + { x: 3, y: 7, z: 4 }, + { x: 4, y: 6, z: 4 }, + { x: 4, y: 7, z: 4 }, + ]); +}); + +test('Should get tiles for extent that crosses dateline', () => { + const extent = { + minLon: -267.34624, + minLat: 10, + maxLon: 33.8355, + maxLat: 79.16772, + }; + + expect(getTilesForExtent(2.12, extent)).toEqual([ + { x: 3, y: 0, z: 2 }, + { x: 3, y: 1, z: 2 }, + { x: 0, y: 0, z: 2 }, + { x: 0, y: 1, z: 2 }, + { x: 1, y: 0, z: 2 }, + { x: 1, y: 1, z: 2 }, + { x: 2, y: 0, z: 2 }, + { x: 2, y: 1, z: 2 }, + ]); +}); + +test('Should get tiles for extent that crosses dateline and not add tiles in between right and left', () => { + const extent = { + minLon: -183.25917, + minLat: 50.10446, + maxLon: -176.63722, + maxLat: 53.06071, + }; + + expect(getTilesForExtent(6.8, extent)).toEqual([ + { x: 63, y: 20, z: 6 }, + { x: 63, y: 21, z: 6 }, + { x: 0, y: 20, z: 6 }, + { x: 0, y: 21, z: 6 }, + ]); +}); + +test('Should return single tile for zoom level 0', () => { + const extent = { + minLon: -180.39426, + minLat: -85.05113, + maxLon: 270.66456, + maxLat: 85.05113, + }; + + expect(getTilesForExtent(0, extent)).toEqual([{ x: 0, y: 0, z: 0 }]); +}); + +test('Should get tile key', () => { expect(getTileKey(45, 120, 10)).toEqual('10/853/368'); }); -it('Should convert tile key to geojson Polygon', () => { +test('Should convert tile key to geojson Polygon', () => { const geometry = getTileBoundingBox('15/23423/1867'); expect(geometry).toEqual({ top: 82.92546, @@ -35,7 +101,7 @@ it('Should convert tile key to geojson Polygon', () => { }); }); -it('Should convert tile key to geojson Polygon with extra precision', () => { +test('Should convert tile key to geojson Polygon with extra precision', () => { const geometry = getTileBoundingBox('26/19762828/25222702'); expect(geometry).toEqual({ top: 40.7491508, @@ -45,7 +111,7 @@ it('Should convert tile key to geojson Polygon with extra precision', () => { }); }); -it('Should expand extent to align boundaries with tile boundaries', () => { +test('Should expand extent to align boundaries with tile boundaries', () => { const extent = { maxLat: 12.5, maxLon: 102.5, diff --git a/x-pack/plugins/maps/public/classes/util/geo_tile_utils.ts b/x-pack/plugins/maps/public/classes/util/geo_tile_utils.ts index f3fe55b5e47c6af..32343e7275841df 100644 --- a/x-pack/plugins/maps/public/classes/util/geo_tile_utils.ts +++ b/x-pack/plugins/maps/public/classes/util/geo_tile_utils.ts @@ -60,6 +60,31 @@ export function parseTileKey(tileKey: string): { return { x, y, zoom, tileCount }; } +export function getTilesForExtent( + zoom: number, + extent: MapExtent +): Array<{ x: number; y: number; z: number }> { + const tileCount = getTileCount(Math.floor(zoom)); + const minX = longitudeToTile(extent.minLon, tileCount); + const maxX = longitudeToTile(extent.maxLon, tileCount); + const minY = latitudeToTile(extent.maxLat, tileCount); + const maxY = latitudeToTile(extent.minLat, tileCount); + + const tiles: Array<{ x: number; y: number; z: number }> = []; + for (let x = 0; x < tileCount && minX + x <= maxX; x++) { + const tileX = minX + x; + for (let y = 0; y < tileCount && minY + y <= maxY; y++) { + const tileY = minY + y; + tiles.push({ + x: tileX < 0 ? tileCount - Math.abs(tileX) : tileX, + y: tileY < 0 ? tileCount - Math.abs(tileY) : tileY, + z: Math.floor(zoom), + }); + } + } + return tiles; +} + export function getTileKey(lat: number, lon: number, zoom: number): string { const tileCount = getTileCount(zoom); diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/__snapshots__/join_editor.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/__snapshots__/join_editor.test.tsx.snap index 7daacae707ecb68..de9d74f68f9656b 100644 --- a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/__snapshots__/join_editor.test.tsx.snap +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/__snapshots__/join_editor.test.tsx.snap @@ -6,18 +6,13 @@ exports[`Should render callout when joins are disabled 1`] = ` size="xs" >
    - - - + + +
    - - - + + +
    - - - + {' '} +
    diff --git a/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/join_documentation_popover.tsx b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/join_documentation_popover.tsx new file mode 100644 index 000000000000000..1799b7264611dca --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/edit_layer_panel/join_editor/resources/join_documentation_popover.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Component } from 'react'; +import { EuiButtonIcon, EuiLink, EuiPopover, EuiPopoverTitle, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { getDocLinks } from '../../../../kibana_services'; + +interface State { + isPopoverOpen: boolean; +} + +export class JoinDocumentationPopover extends Component<{}, State> { + state: State = { + isPopoverOpen: false, + }; + + _togglePopover = () => { + this.setState((prevState) => ({ + isPopoverOpen: !prevState.isPopoverOpen, + })); + }; + + _closePopover = () => { + this.setState({ + isPopoverOpen: false, + }); + }; + + _renderContent() { + return ( +
    + +

    + +

    +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    +

    + +

    + + + +
    +
    + ); + } + + render() { + return ( + + } + isOpen={this.state.isPopoverOpen} + closePopover={this._closePopover} + repositionOnScroll + ownFocus + > + + + + {this._renderContent()} + + ); + } +} diff --git a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx index 9d3e875ad008451..131883eff40cedf 100644 --- a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx @@ -5,7 +5,6 @@ * 2.0. */ -import _ from 'lodash'; import React, { Component } from 'react'; import classNames from 'classnames'; import { EuiFlexGroup, EuiFlexItem, EuiCallOut } from '@elastic/eui'; @@ -22,7 +21,7 @@ import { Timeslider } from '../timeslider'; import { ToolbarOverlay } from '../toolbar_overlay'; import { EditLayerPanel } from '../edit_layer_panel'; import { AddLayerPanel } from '../add_layer_panel'; -import { getData } from '../../kibana_services'; +import { getData, isScreenshotMode } from '../../kibana_services'; import { RawValue } from '../../../common/constants'; import { FLYOUT_STATE } from '../../reducers/ui'; import { MapSettings } from '../../reducers/map'; @@ -232,7 +231,7 @@ export class MapContainer extends Component { onSingleValueTrigger={onSingleValueTrigger} renderTooltipContent={renderTooltipContent} /> - {!this.props.settings.hideToolbarOverlay && ( + {!this.props.settings.hideToolbarOverlay && !isScreenshotMode() && ( { } if ( - this._prevDisableInteractive === undefined || - this._prevDisableInteractive !== this.props.settings.disableInteractive + !isScreenshotMode() && + (this._prevDisableInteractive === undefined || + this._prevDisableInteractive !== this.props.settings.disableInteractive) ) { this._prevDisableInteractive = this.props.settings.disableInteractive; if (this.props.settings.disableInteractive) { diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/__snapshots__/attribution_control.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/__snapshots__/attribution_control.test.tsx.snap index 324a8f1e7fc4545..014ee4d5f0f2a7a 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/__snapshots__/attribution_control.test.tsx.snap +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/__snapshots__/attribution_control.test.tsx.snap @@ -8,17 +8,15 @@ exports[`AttributionControl is rendered 1`] = ` size="xs" > - - - attribution with link - - , - attribution with no link - + + attribution with link + + , + attribution with no link
    diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.test.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.test.tsx index 630e06f014bc69a..85b4208ae225162 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.test.tsx +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.test.tsx @@ -5,6 +5,12 @@ * 2.0. */ +jest.mock('../../../kibana_services', () => ({ + isScreenshotMode: () => { + return false; + }, +})); + import React from 'react'; import { shallow } from 'enzyme'; import { ILayer } from '../../../classes/layers/layer'; diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.tsx index 4b42bc482a7028c..098f603a990610a 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.tsx +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.tsx @@ -11,6 +11,7 @@ import { EuiText, EuiLink } from '@elastic/eui'; import classNames from 'classnames'; import { Attribution } from '../../../../common/descriptor_types'; import { ILayer } from '../../../classes/layers/layer'; +import { isScreenshotMode } from '../../../kibana_services'; export interface Props { isFullScreen: boolean; @@ -74,11 +75,9 @@ export class AttributionControl extends Component { }; _renderAttribution({ url, label }: Attribution) { - if (!url) { - return label; - } - - return ( + return !url || isScreenshotMode() ? ( + label + ) : ( {label} @@ -108,9 +107,7 @@ export class AttributionControl extends Component { })} > - - {this._renderAttributions()} - + {this._renderAttributions()}
    ); diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_control.test.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_control.test.tsx index 0526eddc6521d28..649999ab49a9dd3 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_control.test.tsx +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_control.test.tsx @@ -11,6 +11,12 @@ jest.mock('./layer_toc', () => ({ }, })); +jest.mock('../../../kibana_services', () => ({ + isScreenshotMode: () => { + return false; + }, +})); + import React from 'react'; import { shallow } from 'enzyme'; diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_control.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_control.tsx index 0e692cb13023793..d131bf9b98026d9 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_control.tsx +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_control.tsx @@ -20,6 +20,7 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { LayerTOC } from './layer_toc'; +import { isScreenshotMode } from '../../../kibana_services'; import { ILayer } from '../../../classes/layers/layer'; export interface Props { @@ -82,6 +83,9 @@ export function LayerControl({ isFlyoutOpen, }: Props) { if (!isLayerTOCOpen) { + if (isScreenshotMode()) { + return null; + } const hasErrors = layerList.some((layer) => { return layer.hasErrors(); }); diff --git a/x-pack/plugins/maps/public/inspector/index.ts b/x-pack/plugins/maps/public/inspector/index.ts new file mode 100644 index 000000000000000..149e5150d641f5a --- /dev/null +++ b/x-pack/plugins/maps/public/inspector/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { MapAdapter, MapInspectorView } from './map_adapter'; +export { VectorTileAdapter, VectorTileInspectorView } from './vector_tile_adapter'; diff --git a/x-pack/plugins/maps/public/inspector/map_adapter/index.ts b/x-pack/plugins/maps/public/inspector/map_adapter/index.ts new file mode 100644 index 000000000000000..397906aa563e42d --- /dev/null +++ b/x-pack/plugins/maps/public/inspector/map_adapter/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { MapAdapter } from './map_adapter'; +export { MapInspectorView } from './map_inspector_view'; diff --git a/x-pack/plugins/maps/public/inspector/map_adapter.ts b/x-pack/plugins/maps/public/inspector/map_adapter/map_adapter.ts similarity index 100% rename from x-pack/plugins/maps/public/inspector/map_adapter.ts rename to x-pack/plugins/maps/public/inspector/map_adapter/map_adapter.ts diff --git a/x-pack/plugins/maps/public/inspector/map_details.tsx b/x-pack/plugins/maps/public/inspector/map_adapter/map_details.tsx similarity index 100% rename from x-pack/plugins/maps/public/inspector/map_details.tsx rename to x-pack/plugins/maps/public/inspector/map_adapter/map_details.tsx diff --git a/x-pack/plugins/maps/public/inspector/map_inspector_view.tsx b/x-pack/plugins/maps/public/inspector/map_adapter/map_inspector_view.tsx similarity index 95% rename from x-pack/plugins/maps/public/inspector/map_inspector_view.tsx rename to x-pack/plugins/maps/public/inspector/map_adapter/map_inspector_view.tsx index 7f65a630b72bdb1..d320dc4e9ed1cb1 100644 --- a/x-pack/plugins/maps/public/inspector/map_inspector_view.tsx +++ b/x-pack/plugins/maps/public/inspector/map_adapter/map_inspector_view.tsx @@ -8,7 +8,7 @@ import React, { lazy } from 'react'; import type { Adapters } from '@kbn/inspector-plugin/public'; import { i18n } from '@kbn/i18n'; -import { LazyWrapper } from '../lazy_wrapper'; +import { LazyWrapper } from '../../lazy_wrapper'; const getLazyComponent = () => { return lazy(() => import('./map_view_component')); diff --git a/x-pack/plugins/maps/public/inspector/map_view_component.tsx b/x-pack/plugins/maps/public/inspector/map_adapter/map_view_component.tsx similarity index 100% rename from x-pack/plugins/maps/public/inspector/map_view_component.tsx rename to x-pack/plugins/maps/public/inspector/map_adapter/map_view_component.tsx diff --git a/x-pack/plugins/maps/public/inspector/types.ts b/x-pack/plugins/maps/public/inspector/map_adapter/types.ts similarity index 100% rename from x-pack/plugins/maps/public/inspector/types.ts rename to x-pack/plugins/maps/public/inspector/map_adapter/types.ts diff --git a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/empty_prompt.tsx b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/empty_prompt.tsx new file mode 100644 index 000000000000000..db9a905a86afa0c --- /dev/null +++ b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/empty_prompt.tsx @@ -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 React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiEmptyPrompt } from '@elastic/eui'; + +export function EmptyPrompt() { + return ( + + + + } + body={ + +

    + +

    +
    + } + /> + ); +} diff --git a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.test.ts b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.test.ts new file mode 100644 index 000000000000000..a45be3cf80ec042 --- /dev/null +++ b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.test.ts @@ -0,0 +1,123 @@ +/* + * 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 { getTileRequest } from './get_tile_request'; + +test('Should return elasticsearch vector tile request for aggs tiles', () => { + expect( + getTileRequest({ + layerId: '1', + tileUrl: `/pof/api/maps/mvt/getGridTile/{z}/{x}/{y}.pbf?geometryFieldName=geo.coordinates&index=kibana_sample_data_logs&gridPrecision=8&requestBody=(_source%3A(excludes%3A!())%2Caggs%3A()%2Cfields%3A!((field%3A'%40timestamp'%2Cformat%3Adate_time)%2C(field%3Atimestamp%2Cformat%3Adate_time)%2C(field%3Autc_time%2Cformat%3Adate_time))%2Cquery%3A(bool%3A(filter%3A!((match_phrase%3A(machine.os.keyword%3Aios))%2C(range%3A(timestamp%3A(format%3Astrict_date_optional_time%2Cgte%3A'2022-04-22T16%3A46%3A00.744Z'%2Clte%3A'2022-04-29T16%3A46%3A05.345Z'))))%2Cmust%3A!()%2Cmust_not%3A!()%2Cshould%3A!()))%2Cruntime_mappings%3A(hour_of_day%3A(script%3A(source%3A'emit(doc%5B!'timestamp!'%5D.value.getHour())%3B')%2Ctype%3Along))%2Cscript_fields%3A()%2Csize%3A0%2Cstored_fields%3A!('*'))&renderAs=heatmap&token=e8bff005-ccea-464a-ae56-2061b4f8ce68`, + x: 3, + y: 0, + z: 2, + }) + ).toEqual({ + path: '/kibana_sample_data_logs/_mvt/geo.coordinates/2/3/0', + body: { + size: 0, + grid_precision: 8, + exact_bounds: false, + extent: 4096, + query: { + bool: { + filter: [ + { + match_phrase: { + 'machine.os.keyword': 'ios', + }, + }, + { + range: { + timestamp: { + format: 'strict_date_optional_time', + gte: '2022-04-22T16:46:00.744Z', + lte: '2022-04-29T16:46:05.345Z', + }, + }, + }, + ], + must: [], + must_not: [], + should: [], + }, + }, + grid_agg: 'geotile', + grid_type: 'centroid', + aggs: {}, + fields: [ + { + field: '@timestamp', + format: 'date_time', + }, + { + field: 'timestamp', + format: 'date_time', + }, + { + field: 'utc_time', + format: 'date_time', + }, + ], + runtime_mappings: { + hour_of_day: { + script: { + source: "emit(doc['timestamp'].value.getHour());", + }, + type: 'long', + }, + }, + }, + }); +}); + +test('Should return elasticsearch vector tile request for hits tiles', () => { + expect( + getTileRequest({ + layerId: '1', + tileUrl: `http://localhost:5601/pof/api/maps/mvt/getTile/{z}/{x}/{y}.pbf?geometryFieldName=geo.coordinates&index=kibana_sample_data_logs&requestBody=(_source%3A!f%2Cdocvalue_fields%3A!()%2Cquery%3A(bool%3A(filter%3A!((range%3A(timestamp%3A(format%3Astrict_date_optional_time%2Cgte%3A%272022-04-22T16%3A46%3A00.744Z%27%2Clte%3A%272022-04-29T16%3A46%3A05.345Z%27))))%2Cmust%3A!()%2Cmust_not%3A!()%2Cshould%3A!()))%2Cruntime_mappings%3A(hour_of_day%3A(script%3A(source%3A%27emit(doc%5B!%27timestamp!%27%5D.value.getHour())%3B%27)%2Ctype%3Along))%2Cscript_fields%3A()%2Csize%3A10000%2Cstored_fields%3A!(geo.coordinates))&token=415049b6-bb0a-444a-a7b9-89717db5183c`, + x: 0, + y: 0, + z: 2, + }) + ).toEqual({ + path: '/kibana_sample_data_logs/_mvt/geo.coordinates/2/0/0', + body: { + grid_precision: 0, + exact_bounds: true, + extent: 4096, + query: { + bool: { + filter: [ + { + range: { + timestamp: { + format: 'strict_date_optional_time', + gte: '2022-04-22T16:46:00.744Z', + lte: '2022-04-29T16:46:05.345Z', + }, + }, + }, + ], + must: [], + must_not: [], + should: [], + }, + }, + fields: [], + runtime_mappings: { + hour_of_day: { + script: { + source: "emit(doc['timestamp'].value.getHour());", + }, + type: 'long', + }, + }, + track_total_hits: 10001, + }, + }); +}); diff --git a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.ts b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.ts new file mode 100644 index 000000000000000..f483dfda23409d4 --- /dev/null +++ b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/get_tile_request.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 { + MVT_GETGRIDTILE_API_PATH, + MVT_GETTILE_API_PATH, + RENDER_AS, +} from '../../../../common/constants'; +import { getAggsTileRequest, getHitsTileRequest } from '../../../../common/mvt_request_body'; +import type { TileRequest } from '../types'; + +function getSearchParams(url: string): URLSearchParams { + const split = url.split('?'); + const queryString = split.length <= 1 ? '' : split[1]; + return new URLSearchParams(queryString); +} + +export function getTileRequest(tileRequest: TileRequest): { path?: string; body?: object } { + const searchParams = getSearchParams(tileRequest.tileUrl); + const encodedRequestBody = searchParams.has('requestBody') + ? (searchParams.get('requestBody') as string) + : '()'; + + if (!searchParams.has('index')) { + throw new Error(`Required query parameter 'index' not provided.`); + } + const index = searchParams.get('index') as string; + + if (!searchParams.has('geometryFieldName')) { + throw new Error(`Required query parameter 'geometryFieldName' not provided.`); + } + const geometryFieldName = searchParams.get('geometryFieldName') as string; + + if (tileRequest.tileUrl.includes(MVT_GETGRIDTILE_API_PATH)) { + return getAggsTileRequest({ + encodedRequestBody, + geometryFieldName, + gridPrecision: parseInt(searchParams.get('gridPrecision') as string, 10), + index, + renderAs: searchParams.get('renderAs') as RENDER_AS, + x: tileRequest.x, + y: tileRequest.y, + z: tileRequest.z, + }); + } + + if (tileRequest.tileUrl.includes(MVT_GETTILE_API_PATH)) { + return getHitsTileRequest({ + encodedRequestBody, + geometryFieldName, + index, + x: tileRequest.x, + y: tileRequest.y, + z: tileRequest.z, + }); + } + + throw new Error('Unexpected path'); +} diff --git a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/requests_view_callout.tsx b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/requests_view_callout.tsx new file mode 100644 index 000000000000000..0db29afaf7aef40 --- /dev/null +++ b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/requests_view_callout.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiCallOut } from '@elastic/eui'; + +export function RequestsViewCallout() { + return ( + + ); +} diff --git a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/tile_request_tab.tsx b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/tile_request_tab.tsx new file mode 100644 index 000000000000000..e57216a024b294c --- /dev/null +++ b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/tile_request_tab.tsx @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { + EuiButtonEmpty, + EuiCallOut, + EuiCopy, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import { XJsonLang } from '@kbn/monaco'; +import { CodeEditor } from '@kbn/kibana-react-plugin/public'; +import { compressToEncodedURIComponent } from 'lz-string'; +import { + getDevToolsCapabilities, + getNavigateToUrl, + getShareService, +} from '../../../kibana_services'; +import type { TileRequest } from '../types'; +import { getTileRequest } from './get_tile_request'; + +interface Props { + tileRequest: TileRequest; +} + +export function TileRequestTab(props: Props) { + try { + const { path, body } = getTileRequest(props.tileRequest); + const consoleRequest = `POST ${path}\n${JSON.stringify(body, null, 2)}`; + let consoleHref: string | undefined; + if (getDevToolsCapabilities().show) { + const devToolsDataUri = compressToEncodedURIComponent(consoleRequest); + consoleHref = getShareService() + .url.locators.get('CONSOLE_APP_LOCATOR') + ?.useUrl({ loadFrom: `data:text/plain,${devToolsDataUri}` }); + } + return ( + + + + + +
    + + {(copy) => ( + + {i18n.translate( + 'xpack.maps.inspector.vectorTileRequest.copyToClipboardLabel', + { + defaultMessage: 'Copy to clipboard', + } + )} + + )} + +
    +
    + {consoleHref !== undefined && ( + +
    + { + const navigateToUrl = getNavigateToUrl(); + navigateToUrl(consoleHref!); + }} + iconType="wrench" + > + {i18n.translate('xpack.maps.inspector.vectorTileRequest.openInConsoleLabel', { + defaultMessage: 'Open in Console', + })} + +
    +
    + )} +
    +
    + + + +
    + ); + } catch (e) { + return ( + +

    + {i18n.translate('xpack.maps.inspector.vectorTileRequest.errorTitle', { + defaultMessage: `Could not convert tile request, '{tileUrl}', to Elasticesarch vector tile search request, error: {error}`, + values: { + tileUrl: props.tileRequest.tileUrl, + error: e.message, + }, + })} +

    +
    + ); + } +} diff --git a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/vector_tile_inspector.tsx b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/vector_tile_inspector.tsx new file mode 100644 index 000000000000000..9a9356ad1a6d2fe --- /dev/null +++ b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/components/vector_tile_inspector.tsx @@ -0,0 +1,178 @@ +/* + * 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 _ from 'lodash'; +import React, { Component } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { Adapters } from '@kbn/inspector-plugin/public'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiSpacer, EuiTabs, EuiTab } from '@elastic/eui'; +import { EmptyPrompt } from './empty_prompt'; +import type { TileRequest } from '../types'; +import { TileRequestTab } from './tile_request_tab'; +import { RequestsViewCallout } from './requests_view_callout'; + +interface Props { + adapters: Adapters; +} + +interface State { + selectedLayer: EuiComboBoxOptionOption | null; + selectedTileRequest: TileRequest | null; + tileRequests: TileRequest[]; + layerOptions: Array>; +} + +class VectorTileInspector extends Component { + private _isMounted = false; + + state: State = { + selectedLayer: null, + selectedTileRequest: null, + tileRequests: [], + layerOptions: [], + }; + + componentDidMount() { + this._isMounted = true; + this._onAdapterChange(); + this.props.adapters.vectorTiles.on('change', this._debouncedOnAdapterChange); + } + + componentWillUnmount() { + this._isMounted = false; + this.props.adapters.vectorTiles.removeListener('change', this._debouncedOnAdapterChange); + } + + _onAdapterChange = () => { + const layerOptions = this.props.adapters.vectorTiles.getLayerOptions() as Array< + EuiComboBoxOptionOption + >; + if (layerOptions.length === 0) { + this.setState({ + selectedLayer: null, + selectedTileRequest: null, + tileRequests: [], + layerOptions: [], + }); + return; + } + + const selectedLayer = + this.state.selectedLayer && + layerOptions.some((layerOption) => { + return this.state.selectedLayer?.value === layerOption.value; + }) + ? this.state.selectedLayer + : layerOptions[0]; + const tileRequests = this.props.adapters.vectorTiles.getTileRequests(selectedLayer.value); + const selectedTileRequest = + this.state.selectedTileRequest && + tileRequests.some((tileRequest: TileRequest) => { + return ( + this.state.selectedTileRequest?.layerId === tileRequest.layerId && + this.state.selectedTileRequest?.x === tileRequest.x && + this.state.selectedTileRequest?.y === tileRequest.y && + this.state.selectedTileRequest?.z === tileRequest.z + ); + }) + ? this.state.selectedTileRequest + : tileRequests.length + ? tileRequests[0] + : null; + + this.setState({ + selectedLayer, + selectedTileRequest, + tileRequests, + layerOptions, + }); + }; + + _debouncedOnAdapterChange = _.debounce(() => { + if (this._isMounted) { + this._onAdapterChange(); + } + }, 256); + + _onLayerSelect = (selectedOptions: Array>) => { + if (selectedOptions.length === 0) { + this.setState({ + selectedLayer: null, + selectedTileRequest: null, + tileRequests: [], + }); + return; + } + + const selectedLayer = selectedOptions[0]; + const tileRequests = this.props.adapters.vectorTiles.getTileRequests(selectedLayer.value); + this.setState({ + selectedLayer, + selectedTileRequest: tileRequests.length ? tileRequests[0] : null, + tileRequests, + }); + }; + + renderTabs() { + return this.state.tileRequests.map((tileRequest) => { + const tileLabel = `${tileRequest.z}/${tileRequest.x}/${tileRequest.y}`; + return ( + { + this.setState({ selectedTileRequest: tileRequest }); + }} + isSelected={ + tileRequest.layerId === this.state.selectedTileRequest?.layerId && + tileRequest.x === this.state.selectedTileRequest?.x && + tileRequest.y === this.state.selectedTileRequest?.y && + tileRequest.z === this.state.selectedTileRequest?.z + } + > + {tileLabel} + + ); + }); + } + + render() { + return this.state.layerOptions.length === 0 ? ( + <> + + + + ) : ( + <> + + + + + {this.renderTabs()} + + {this.state.selectedTileRequest && ( + + )} + + ); + } +} + +// default export required for React.Lazy +// eslint-disable-next-line import/no-default-export +export default VectorTileInspector; diff --git a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/index.ts b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/index.ts new file mode 100644 index 000000000000000..12dc5b318daba70 --- /dev/null +++ b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { VectorTileAdapter } from './vector_tile_adapter'; +export { VectorTileInspectorView } from './vector_tile_inspector_view'; diff --git a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/types.ts b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/types.ts new file mode 100644 index 000000000000000..5fead09165fad27 --- /dev/null +++ b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/types.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface TileRequest { + layerId: string; + tileUrl: string; + x: number; + y: number; + z: number; +} diff --git a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/vector_tile_adapter.ts b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/vector_tile_adapter.ts new file mode 100644 index 000000000000000..d0210367ab3b3b1 --- /dev/null +++ b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/vector_tile_adapter.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 { EventEmitter } from 'events'; +import { TileRequest } from './types'; + +export class VectorTileAdapter extends EventEmitter { + private _layers: Record = {}; + private _tiles: Array<{ x: number; y: number; z: number }> = []; + + addLayer(layerId: string, label: string, tileUrl: string) { + this._layers[layerId] = { label, tileUrl }; + this._onChange(); + } + + removeLayer(layerId: string) { + delete this._layers[layerId]; + this._onChange(); + } + + setTiles(tiles: Array<{ x: number; y: number; z: number }>) { + this._tiles = tiles; + this._onChange(); + } + + getLayerOptions(): Array<{ value: string; label: string }> { + return Object.keys(this._layers).map((layerId) => { + return { + value: layerId, + label: this._layers[layerId].label, + }; + }); + } + + getTileRequests(layerId: string): TileRequest[] { + if (!this._layers[layerId]) { + return []; + } + + const { tileUrl } = this._layers[layerId]; + return this._tiles.map((tile) => { + return { + layerId, + tileUrl, + ...tile, + }; + }); + } + + _onChange() { + this.emit('change'); + } +} diff --git a/x-pack/plugins/maps/public/inspector/vector_tile_adapter/vector_tile_inspector_view.tsx b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/vector_tile_inspector_view.tsx new file mode 100644 index 000000000000000..42d7423e6e78906 --- /dev/null +++ b/x-pack/plugins/maps/public/inspector/vector_tile_adapter/vector_tile_inspector_view.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { lazy } from 'react'; +import type { Adapters } from '@kbn/inspector-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { LazyWrapper } from '../../lazy_wrapper'; + +const getLazyComponent = () => { + return lazy(() => import('./components/vector_tile_inspector')); +}; + +export const VectorTileInspectorView = { + title: i18n.translate('xpack.maps.inspector.vectorTileViewTitle', { + defaultMessage: 'Vector tiles', + }), + order: 10, + help: i18n.translate('xpack.maps.inspector.vectorTileViewHelpText', { + defaultMessage: 'View the vector tile search requests used to collect the data', + }), + shouldShow(adapters: Adapters) { + return Boolean(adapters.vectorTiles); + }, + component: (props: { adapters: Adapters }) => { + return ; + }, +}; diff --git a/x-pack/plugins/maps/public/kibana_services.ts b/x-pack/plugins/maps/public/kibana_services.ts index 22857c623c18a93..5774a4668464420 100644 --- a/x-pack/plugins/maps/public/kibana_services.ts +++ b/x-pack/plugins/maps/public/kibana_services.ts @@ -46,6 +46,7 @@ export const getTimeFilter = () => pluginsStart.data.query.timefilter.timefilter export const getToasts = () => coreStart.notifications.toasts; export const getSavedObjectsClient = () => coreStart.savedObjects.client; export const getCoreChrome = () => coreStart.chrome; +export const getDevToolsCapabilities = () => coreStart.application.capabilities.dev_tools; export const getMapsCapabilities = () => coreStart.application.capabilities.maps; export const getVisualizeCapabilities = () => coreStart.application.capabilities.visualize; export const getDocLinks = () => coreStart.docLinks; @@ -58,6 +59,7 @@ export const getCoreI18n = () => coreStart.i18n; export const getSearchService = () => pluginsStart.data.search; export const getEmbeddableService = () => pluginsStart.embeddable; export const getNavigateToApp = () => coreStart.application.navigateToApp; +export const getNavigateToUrl = () => coreStart.application.navigateToUrl; export const getSavedObjectsTagging = () => pluginsStart.savedObjectsTagging; export const getPresentationUtilContext = () => pluginsStart.presentationUtil.ContextProvider; export const getSecurityService = () => pluginsStart.security; @@ -65,6 +67,9 @@ export const getSpacesApi = () => pluginsStart.spaces; export const getTheme = () => coreStart.theme; export const getUsageCollection = () => pluginsStart.usageCollection; export const getApplication = () => coreStart.application; +export const isScreenshotMode = () => { + return pluginsStart.screenshotMode ? pluginsStart.screenshotMode.isScreenshotMode() : false; +}; // xpack.maps.* kibana.yml settings from this plugin let mapAppConfig: MapsConfigType; diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/choropleth_chart.tsx b/x-pack/plugins/maps/public/lens/choropleth_chart/choropleth_chart.tsx index 5089a8cc6c8d5d4..467c2ce92ff6e90 100644 --- a/x-pack/plugins/maps/public/lens/choropleth_chart/choropleth_chart.tsx +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/choropleth_chart.tsx @@ -45,12 +45,10 @@ export function ChoroplethChart({ return null; } - const table = data.tables[args.layerId]; - let emsLayerId = args.emsLayerId ? args.emsLayerId : emsWorldLayerId; let emsField = args.emsField ? args.emsField : 'iso2'; if (!args.emsLayerId || !args.emsField) { - const emsSuggestion = getEmsSuggestion(emsFileLayers, table, args.regionAccessor); + const emsSuggestion = getEmsSuggestion(emsFileLayers, data, args.regionAccessor); if (emsSuggestion) { emsLayerId = emsSuggestion.layerId; emsField = emsSuggestion.field; @@ -66,7 +64,7 @@ export function ChoroplethChart({ defaultMessage: '{emsLayerLabel} by {accessorLabel}', values: { emsLayerLabel, - accessorLabel: getAccessorLabel(table, args.valueAccessor), + accessorLabel: getAccessorLabel(data, args.valueAccessor), }, }) : '', @@ -76,16 +74,16 @@ export function ChoroplethChart({ right: { id: args.valueAccessor, type: SOURCE_TYPES.TABLE_SOURCE, - __rows: table.rows, + __rows: data.rows, __columns: [ { name: args.regionAccessor, - label: getAccessorLabel(table, args.regionAccessor), + label: getAccessorLabel(data, args.regionAccessor), type: 'string', }, { name: args.valueAccessor, - label: getAccessorLabel(table, args.valueAccessor), + label: getAccessorLabel(data, args.valueAccessor), type: 'number', }, ], diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/expression_function.ts b/x-pack/plugins/maps/public/lens/choropleth_chart/expression_function.ts index 7ed1ddfbd438171..989cc06c5d53be2 100644 --- a/x-pack/plugins/maps/public/lens/choropleth_chart/expression_function.ts +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/expression_function.ts @@ -6,8 +6,8 @@ */ import type { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common'; +import { Datatable } from '@kbn/expressions-plugin/common'; import { i18n } from '@kbn/i18n'; -import type { LensMultiTable } from '@kbn/lens-plugin/common'; import { prepareLogTable } from '@kbn/visualizations-plugin/common/utils'; import type { ChoroplethChartConfig, ChoroplethChartProps } from './types'; import { RENDERER_ID } from './expression_renderer'; @@ -20,7 +20,7 @@ interface ChoroplethChartRender { export const getExpressionFunction = (): ExpressionFunctionDefinition< 'lens_choropleth_chart', - LensMultiTable, + Datatable, Omit, ChoroplethChartRender > => ({ @@ -57,11 +57,14 @@ export const getExpressionFunction = (): ExpressionFunctionDefinition< help: 'Value accessor identifies the value column', }, }, - inputTypes: ['lens_multitable'], + inputTypes: ['datatable'], fn(data, args, handlers) { if (handlers?.inspectorAdapters?.tables) { + handlers.inspectorAdapters.tables.reset(); + handlers.inspectorAdapters.tables.allowCsvExport = true; + const logTable = prepareLogTable( - Object.values(data.tables)[0], + data, [ [ args.valueAccessor ? [args.valueAccessor] : undefined, @@ -88,6 +91,6 @@ export const getExpressionFunction = (): ExpressionFunctionDefinition< data, args, }, - } as ChoroplethChartRender; + }; }, }); diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/types.ts b/x-pack/plugins/maps/public/lens/choropleth_chart/types.ts index 79c05a93ef2d481..7dc9a16056e77d7 100644 --- a/x-pack/plugins/maps/public/lens/choropleth_chart/types.ts +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { LensMultiTable } from '@kbn/lens-plugin/common'; +import { Datatable } from '@kbn/expressions-plugin/common'; export interface ChoroplethChartState { layerId: string; @@ -21,6 +21,6 @@ export interface ChoroplethChartConfig extends ChoroplethChartState { } export interface ChoroplethChartProps { - data: LensMultiTable; + data: Datatable; args: ChoroplethChartConfig; } diff --git a/x-pack/plugins/maps/public/lens/choropleth_chart/visualization.tsx b/x-pack/plugins/maps/public/lens/choropleth_chart/visualization.tsx index cbac26f220163d0..54f459c3f7b3895 100644 --- a/x-pack/plugins/maps/public/lens/choropleth_chart/visualization.tsx +++ b/x-pack/plugins/maps/public/lens/choropleth_chart/visualization.tsx @@ -138,14 +138,16 @@ export const getVisualization = ({ } }, - toExpression: (state, datasourceLayers, attributes) => { + toExpression: (state, datasourceLayers, attributes, datasourceExpressionsByLayers = {}) => { if (!state.regionAccessor || !state.valueAccessor) { return null; } + const datasourceExpression = datasourceExpressionsByLayers[state.layerId]; return { type: 'expression', chain: [ + ...(datasourceExpression ? datasourceExpression.chain : []), { type: 'function', function: 'lens_choropleth_chart', diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index 5e9662c54364183..846e7fc3d83f7b2 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -41,6 +41,7 @@ import type { SecurityPluginStart } from '@kbn/security-plugin/public'; import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import type { CloudSetup } from '@kbn/cloud-plugin/public'; import type { LensPublicSetup } from '@kbn/lens-plugin/public'; +import { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/public'; import { createRegionMapFn, regionMapRenderer, @@ -73,7 +74,7 @@ import { APP_ICON_SOLUTION, APP_ID, MAP_SAVED_OBJECT_TYPE } from '../common/cons import { getMapsVisTypeAlias } from './maps_vis_type_alias'; import { featureCatalogueEntry } from './feature_catalogue_entry'; import { setIsCloudEnabled, setMapAppConfig, setStartServices } from './kibana_services'; -import { MapInspectorView } from './inspector/map_inspector_view'; +import { MapInspectorView, VectorTileInspectorView } from './inspector'; import { setupLensChoroplethChart } from './lens'; @@ -88,6 +89,7 @@ export interface MapsPluginSetupDependencies { share: SharePluginSetup; licensing: LicensingPluginSetup; usageCollection?: UsageCollectionSetup; + screenshotMode?: ScreenshotModePluginSetup; } export interface MapsPluginStartDependencies { @@ -110,6 +112,7 @@ export interface MapsPluginStartDependencies { security?: SecurityPluginStart; spaces?: SpacesPluginStart; mapsEms: MapsEmsPluginPublicStart; + screenshotMode?: ScreenshotModePluginSetup; usageCollection?: UsageCollectionSetup; } @@ -144,7 +147,15 @@ export class MapsPlugin registerLicensedFeatures(plugins.licensing); const config = this._initializerContext.config.get(); - setMapAppConfig(config); + setMapAppConfig({ + ...config, + + // Override this when we know we are taking a screenshot (i.e. no user interaction) + // to avoid a blank-canvas issue when rendering maps on a PDF + preserveDrawingBuffer: plugins.screenshotMode?.isScreenshotMode() + ? true + : config.preserveDrawingBuffer, + }); const locator = plugins.share.url.locators.create( new MapsAppLocatorDefinition({ @@ -162,6 +173,7 @@ export class MapsPlugin }) ); + plugins.inspector.registerView(VectorTileInspectorView); plugins.inspector.registerView(MapInspectorView); if (plugins.home) { plugins.home.featureCatalogue.register(featureCatalogueEntry); diff --git a/x-pack/plugins/maps/public/reducers/non_serializable_instances.js b/x-pack/plugins/maps/public/reducers/non_serializable_instances.js index 7f43fe6f703984a..fe1dba55051086b 100644 --- a/x-pack/plugins/maps/public/reducers/non_serializable_instances.js +++ b/x-pack/plugins/maps/public/reducers/non_serializable_instances.js @@ -6,7 +6,7 @@ */ import { RequestAdapter } from '@kbn/inspector-plugin/common/adapters/request'; -import { MapAdapter } from '../inspector/map_adapter'; +import { MapAdapter, VectorTileAdapter } from '../inspector'; import { getShowMapsInspectorAdapter } from '../kibana_services'; const REGISTER_CANCEL_CALLBACK = 'REGISTER_CANCEL_CALLBACK'; @@ -17,6 +17,7 @@ const SET_CHARTS_PALETTE_SERVICE_GET_COLOR = 'SET_CHARTS_PALETTE_SERVICE_GET_COL function createInspectorAdapters() { const inspectorAdapters = { requests: new RequestAdapter(), + vectorTiles: new VectorTileAdapter(), }; if (getShowMapsInspectorAdapter()) { inspectorAdapters.map = new MapAdapter(); diff --git a/x-pack/plugins/maps/server/mvt/get_grid_tile.ts b/x-pack/plugins/maps/server/mvt/get_grid_tile.ts deleted file mode 100644 index eb0ddc9e13143af..000000000000000 --- a/x-pack/plugins/maps/server/mvt/get_grid_tile.ts +++ /dev/null @@ -1,96 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { CoreStart, Logger } from '@kbn/core/server'; -import type { DataRequestHandlerContext } from '@kbn/data-plugin/server'; -import { IncomingHttpHeaders } from 'http'; -import { Stream } from 'stream'; -import { RENDER_AS } from '../../common/constants'; -import { isAbortError } from './util'; -import { makeExecutionContext } from '../../common/execution_context'; - -export async function getEsGridTile({ - url, - core, - logger, - context, - index, - geometryFieldName, - x, - y, - z, - requestBody = {}, - renderAs = RENDER_AS.POINT, - gridPrecision, - abortController, -}: { - url: string; - core: CoreStart; - x: number; - y: number; - z: number; - geometryFieldName: string; - index: string; - context: DataRequestHandlerContext; - logger: Logger; - requestBody: any; - renderAs: RENDER_AS; - gridPrecision: number; - abortController: AbortController; -}): Promise<{ stream: Stream | null; headers: IncomingHttpHeaders; statusCode: number }> { - try { - const path = `/${encodeURIComponent(index)}/_mvt/${geometryFieldName}/${z}/${x}/${y}`; - const body = { - size: 0, // no hits - grid_precision: gridPrecision, - exact_bounds: false, - extent: 4096, // full resolution, - query: requestBody.query, - grid_agg: renderAs === RENDER_AS.HEX ? 'geohex' : 'geotile', - grid_type: renderAs === RENDER_AS.GRID || renderAs === RENDER_AS.HEX ? 'grid' : 'centroid', - aggs: requestBody.aggs, - fields: requestBody.fields, - runtime_mappings: requestBody.runtime_mappings, - }; - - const esClient = (await context.core).elasticsearch.client; - const tile = await core.executionContext.withContext( - makeExecutionContext({ - description: 'mvt:get_grid_tile', - url, - }), - async () => { - return await esClient.asCurrentUser.transport.request( - { - method: 'GET', - path, - body, - }, - { - signal: abortController.signal, - headers: { - 'Accept-Encoding': 'gzip', - }, - asStream: true, - meta: true, - } - ); - } - ); - - return { stream: tile.body as Stream, headers: tile.headers, statusCode: tile.statusCode }; - } catch (e) { - if (isAbortError(e)) { - return { stream: null, headers: {}, statusCode: 200 }; - } - - // These are often circuit breaking exceptions - // Should return a tile with some error message - logger.warn(`Cannot generate ES-grid-tile for ${z}/${x}/${y}: ${e.message}`); - return { stream: null, headers: {}, statusCode: 500 }; - } -} diff --git a/x-pack/plugins/maps/server/mvt/get_tile.ts b/x-pack/plugins/maps/server/mvt/get_tile.ts deleted file mode 100644 index 340a71128b43ae9..000000000000000 --- a/x-pack/plugins/maps/server/mvt/get_tile.ts +++ /dev/null @@ -1,96 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { CoreStart, Logger } from '@kbn/core/server'; -import type { DataRequestHandlerContext } from '@kbn/data-plugin/server'; -import { IncomingHttpHeaders } from 'http'; -import { Stream } from 'stream'; -import { isAbortError } from './util'; -import { makeExecutionContext } from '../../common/execution_context'; -import { Field, mergeFields } from './merge_fields'; - -export async function getEsTile({ - url, - core, - logger, - context, - index, - geometryFieldName, - x, - y, - z, - requestBody = {}, - abortController, -}: { - url: string; - core: CoreStart; - x: number; - y: number; - z: number; - geometryFieldName: string; - index: string; - context: DataRequestHandlerContext; - logger: Logger; - requestBody: any; - abortController: AbortController; -}): Promise<{ stream: Stream | null; headers: IncomingHttpHeaders; statusCode: number }> { - try { - const path = `/${encodeURIComponent(index)}/_mvt/${geometryFieldName}/${z}/${x}/${y}`; - - const body = { - grid_precision: 0, // no aggs - exact_bounds: true, - extent: 4096, // full resolution, - query: requestBody.query, - fields: mergeFields( - [ - requestBody.docvalue_fields as Field[] | undefined, - requestBody.stored_fields as Field[] | undefined, - ], - [geometryFieldName] - ), - runtime_mappings: requestBody.runtime_mappings, - track_total_hits: requestBody.size + 1, - }; - - const esClient = (await context.core).elasticsearch.client; - const tile = await core.executionContext.withContext( - makeExecutionContext({ - description: 'mvt:get_tile', - url, - }), - async () => { - return await esClient.asCurrentUser.transport.request( - { - method: 'GET', - path, - body, - }, - { - signal: abortController.signal, - headers: { - 'Accept-Encoding': 'gzip', - }, - asStream: true, - meta: true, - } - ); - } - ); - - return { stream: tile.body as Stream, headers: tile.headers, statusCode: tile.statusCode }; - } catch (e) { - if (isAbortError(e)) { - return { stream: null, headers: {}, statusCode: 200 }; - } - - // These are often circuit breaking exceptions - // Should return a tile with some error message - logger.warn(`Cannot generate ES-grid-tile for ${z}/${x}/${y}: ${e.message}`); - return { stream: null, headers: {}, statusCode: 500 }; - } -} diff --git a/x-pack/plugins/maps/server/mvt/merge_fields.ts b/x-pack/plugins/maps/server/mvt/merge_fields.ts deleted file mode 100644 index e371f3ff0715bd4..000000000000000 --- a/x-pack/plugins/maps/server/mvt/merge_fields.ts +++ /dev/null @@ -1,40 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -// can not use "import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey" -// SearchRequest is incorrectly typed and does not support Field as object -// https://github.com/elastic/elasticsearch-js/issues/1615 -export type Field = - | string - | { - field: string; - format: string; - }; - -export function mergeFields( - fieldsList: Array, - excludeNames: string[] -): Field[] { - const fieldNames: string[] = []; - const mergedFields: Field[] = []; - - fieldsList.forEach((fields) => { - if (!fields) { - return; - } - - fields.forEach((field) => { - const fieldName = typeof field === 'string' ? field : field.field; - if (!excludeNames.includes(fieldName) && !fieldNames.includes(fieldName)) { - fieldNames.push(fieldName); - mergedFields.push(field); - } - }); - }); - - return mergedFields; -} diff --git a/x-pack/plugins/maps/server/mvt/mvt_routes.ts b/x-pack/plugins/maps/server/mvt/mvt_routes.ts index b7c6a59ba54d468..8af26548b1d28d1 100644 --- a/x-pack/plugins/maps/server/mvt/mvt_routes.ts +++ b/x-pack/plugins/maps/server/mvt/mvt_routes.ts @@ -8,18 +8,19 @@ import { Stream } from 'stream'; import { IncomingHttpHeaders } from 'http'; import { schema } from '@kbn/config-schema'; +import type { KibanaExecutionContext } from '@kbn/core/public'; import { CoreStart, KibanaRequest, KibanaResponseFactory, Logger } from '@kbn/core/server'; import { IRouter } from '@kbn/core/server'; import type { DataRequestHandlerContext } from '@kbn/data-plugin/server'; +import { errors } from '@elastic/elasticsearch'; import { MVT_GETTILE_API_PATH, API_ROOT_PATH, MVT_GETGRIDTILE_API_PATH, RENDER_AS, } from '../../common/constants'; -import { decodeMvtResponseBody } from '../../common/mvt_request_body'; -import { getEsTile } from './get_tile'; -import { getEsGridTile } from './get_grid_tile'; +import { makeExecutionContext } from '../../common/execution_context'; +import { getAggsTileRequest, getHitsTileRequest } from '../../common/mvt_request_body'; const CACHE_TIMEOUT_SECONDS = 60 * 60; @@ -55,21 +56,35 @@ export function initMVTRoutes({ response: KibanaResponseFactory ) => { const { query, params } = request; + const x = parseInt((params as any).x, 10) as number; + const y = parseInt((params as any).y, 10) as number; + const z = parseInt((params as any).z, 10) as number; - const abortController = makeAbortController(request); + let tileRequest: { path: string; body: object } | undefined; + try { + tileRequest = getHitsTileRequest({ + encodedRequestBody: query.requestBody as string, + geometryFieldName: query.geometryFieldName as string, + index: query.index as string, + x, + y, + z, + }); + } catch (e) { + return response.badRequest(); + } - const { stream, headers, statusCode } = await getEsTile({ - url: `${API_ROOT_PATH}/${MVT_GETTILE_API_PATH}/{z}/{x}/{y}.pbf`, + const { stream, headers, statusCode } = await getTile({ + abortController: makeAbortController(request), + body: tileRequest.body, + context, core, + executionContext: makeExecutionContext({ + description: 'mvt:get_hits_tile', + url: `${API_ROOT_PATH}/${MVT_GETTILE_API_PATH}/${z}/${x}/${y}.pbf`, + }), logger, - context, - geometryFieldName: query.geometryFieldName as string, - x: parseInt((params as any).x, 10) as number, - y: parseInt((params as any).y, 10) as number, - z: parseInt((params as any).z, 10) as number, - index: query.index as string, - requestBody: decodeMvtResponseBody(query.requestBody as string) as any, - abortController, + path: tileRequest.path, }); return sendResponse(response, stream, headers, statusCode); @@ -101,23 +116,37 @@ export function initMVTRoutes({ response: KibanaResponseFactory ) => { const { query, params } = request; + const x = parseInt((params as any).x, 10) as number; + const y = parseInt((params as any).y, 10) as number; + const z = parseInt((params as any).z, 10) as number; - const abortController = makeAbortController(request); + let tileRequest: { path: string; body: object } | undefined; + try { + tileRequest = getAggsTileRequest({ + encodedRequestBody: query.requestBody as string, + geometryFieldName: query.geometryFieldName as string, + gridPrecision: parseInt(query.gridPrecision, 10), + index: query.index as string, + renderAs: query.renderAs as RENDER_AS, + x, + y, + z, + }); + } catch (e) { + return response.badRequest(); + } - const { stream, headers, statusCode } = await getEsGridTile({ - url: `${API_ROOT_PATH}/${MVT_GETGRIDTILE_API_PATH}/{z}/{x}/{y}.pbf`, + const { stream, headers, statusCode } = await getTile({ + abortController: makeAbortController(request), + body: tileRequest.body, + context, core, + executionContext: makeExecutionContext({ + description: 'mvt:get_aggs_tile', + url: `${API_ROOT_PATH}/${MVT_GETGRIDTILE_API_PATH}/${z}/${x}/${y}.pbf`, + }), logger, - context, - geometryFieldName: query.geometryFieldName as string, - x: parseInt((params as any).x, 10) as number, - y: parseInt((params as any).y, 10) as number, - z: parseInt((params as any).z, 10) as number, - index: query.index as string, - requestBody: decodeMvtResponseBody(query.requestBody as string) as any, - renderAs: query.renderAs as RENDER_AS, - gridPrecision: parseInt(query.gridPrecision, 10), - abortController, + path: tileRequest.path, }); return sendResponse(response, stream, headers, statusCode); @@ -125,6 +154,56 @@ export function initMVTRoutes({ ); } +async function getTile({ + abortController, + body, + context, + core, + executionContext, + logger, + path, +}: { + abortController: AbortController; + body: object; + context: DataRequestHandlerContext; + core: CoreStart; + executionContext: KibanaExecutionContext; + logger: Logger; + path: string; +}) { + try { + const esClient = (await context.core).elasticsearch.client; + const tile = await core.executionContext.withContext(executionContext, async () => { + return await esClient.asCurrentUser.transport.request( + { + method: 'POST', + path, + body, + }, + { + signal: abortController.signal, + headers: { + 'Accept-Encoding': 'gzip', + }, + asStream: true, + meta: true, + } + ); + }); + + return { stream: tile.body as Stream, headers: tile.headers, statusCode: tile.statusCode }; + } catch (e) { + if (e instanceof errors.RequestAbortedError) { + return { stream: null, headers: {}, statusCode: 200 }; + } + + // These are often circuit breaking exceptions + // Should return a tile with some error message + logger.warn(`Cannot generate tile for ${executionContext.url}: ${e.message}`); + return { stream: null, headers: {}, statusCode: 500 }; + } +} + export function sendResponse( response: KibanaResponseFactory, tileStream: Stream | null, diff --git a/x-pack/plugins/maps/tsconfig.json b/x-pack/plugins/maps/tsconfig.json index 5d5f4223fab9ae1..57cc09dec4b169a 100644 --- a/x-pack/plugins/maps/tsconfig.json +++ b/x-pack/plugins/maps/tsconfig.json @@ -33,6 +33,7 @@ { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, { "path": "../../../src/plugins/shared_ux/tsconfig.json" }, + { "path": "../../../src/plugins/screenshot_mode/tsconfig.json" }, { "path": "../cloud/tsconfig.json" }, { "path": "../features/tsconfig.json" }, { "path": "../lens/tsconfig.json" }, diff --git a/x-pack/plugins/ml/common/constants/job_actions.ts b/x-pack/plugins/ml/common/constants/job_actions.ts index 0cd16a10783e34d..692875c73b10563 100644 --- a/x-pack/plugins/ml/common/constants/job_actions.ts +++ b/x-pack/plugins/ml/common/constants/job_actions.ts @@ -5,8 +5,6 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; - export const JOB_ACTION = { DELETE: 'delete', RESET: 'reset', @@ -15,22 +13,16 @@ export const JOB_ACTION = { export type JobAction = typeof JOB_ACTION[keyof typeof JOB_ACTION]; -export function getJobActionString(action: JobAction) { +export type JobActionState = 'deleting' | 'resetting' | 'reverting'; + +export function getJobActionString(action: JobAction): JobActionState { switch (action) { case JOB_ACTION.DELETE: - return i18n.translate('xpack.ml.models.jobService.deletingJob', { - defaultMessage: 'deleting', - }); + return 'deleting'; case JOB_ACTION.RESET: - return i18n.translate('xpack.ml.models.jobService.resettingJob', { - defaultMessage: 'resetting', - }); + return 'resetting'; case JOB_ACTION.REVERT: - return i18n.translate('xpack.ml.models.jobService.revertingJob', { - defaultMessage: 'reverting', - }); - default: - return ''; + return 'reverting'; } } diff --git a/x-pack/plugins/ml/common/constants/locator.ts b/x-pack/plugins/ml/common/constants/locator.ts index 0a1c2638e684a4d..0c19c5b59766c5c 100644 --- a/x-pack/plugins/ml/common/constants/locator.ts +++ b/x-pack/plugins/ml/common/constants/locator.ts @@ -51,6 +51,8 @@ export const ML_PAGES = { FILTER_LISTS_EDIT: 'settings/filter_lists/edit_filter_list', ACCESS_DENIED: 'access-denied', OVERVIEW: 'overview', + AIOPS: 'aiops', + AIOPS_EXPLAIN_LOG_RATE_SPIKES: 'aiops/explain_log_rate_spikes', } as const; export type MlPages = typeof ML_PAGES[keyof typeof ML_PAGES]; diff --git a/x-pack/plugins/ml/common/index.ts b/x-pack/plugins/ml/common/index.ts index de5989d92d20887..cfed678a804a1e4 100644 --- a/x-pack/plugins/ml/common/index.ts +++ b/x-pack/plugins/ml/common/index.ts @@ -21,3 +21,4 @@ export { extractErrorMessage } from './util/errors'; export type { RuntimeMappings } from './types/fields'; export { getDefaultCapabilities as getDefaultMlCapabilities } from './types/capabilities'; export { DATAFEED_STATE, JOB_STATE } from './constants/states'; +export type { MlSummaryJob, SummaryJobState } from './types/anomaly_detection_jobs'; diff --git a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts index fed0cc85c20b031..504fdb8cf1dcd16 100644 --- a/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts +++ b/x-pack/plugins/ml/common/types/anomaly_detection_jobs/summary_job.ts @@ -5,15 +5,19 @@ * 2.0. */ +import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { Moment } from 'moment'; import { MlCustomSettings } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { CombinedJob, CombinedJobWithStats } from './combined_job'; import type { MlAnomalyDetectionAlertRule } from '../alerts'; import type { MlJobBlocked } from './job'; +import type { JobActionState } from '../../constants/job_actions'; export type { Datafeed } from './datafeed'; export type { DatafeedStats } from './datafeed_stats'; +export type SummaryJobState = estypes.MlJobState | JobActionState; + /** * A summary of an anomaly detection job. */ @@ -47,7 +51,7 @@ export interface MlSummaryJob { /** * The status of the job. */ - jobState: string; + jobState: SummaryJobState; /** * An array of index names used by the datafeed. Wildcards are supported. diff --git a/x-pack/plugins/ml/common/types/locator.ts b/x-pack/plugins/ml/common/types/locator.ts index 33ec94b8253035c..a440aaa349bcc00 100644 --- a/x-pack/plugins/ml/common/types/locator.ts +++ b/x-pack/plugins/ml/common/types/locator.ts @@ -60,7 +60,9 @@ export type MlGenericUrlState = MLPageState< | typeof ML_PAGES.ACCESS_DENIED | typeof ML_PAGES.DATA_VISUALIZER | typeof ML_PAGES.DATA_VISUALIZER_FILE - | typeof ML_PAGES.DATA_VISUALIZER_INDEX_SELECT, + | typeof ML_PAGES.DATA_VISUALIZER_INDEX_SELECT + | typeof ML_PAGES.AIOPS + | typeof ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES, MlGenericUrlPageState | undefined >; diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index eb00ca117f01afb..f62cec0ec0fca8e 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -7,6 +7,7 @@ "ml" ], "requiredPlugins": [ + "aiops", "cloud", "data", "dataViews", diff --git a/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx b/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx new file mode 100644 index 000000000000000..473525d40ca9a7f --- /dev/null +++ b/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { ExplainLogRateSpikesSpec } from '@kbn/aiops-plugin/public'; +import { useMlKibana, useTimefilter } from '../contexts/kibana'; +import { HelpMenu } from '../components/help_menu'; + +import { MlPageHeader } from '../components/page_header'; + +export const ExplainLogRateSpikesPage: FC = () => { + useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); + const { + services: { docLinks, aiops }, + } = useMlKibana(); + + const [ExplainLogRateSpikes, setExplainLogRateSpikes] = useState( + null + ); + + useEffect(() => { + if (aiops !== undefined) { + const { getExplainLogRateSpikesComponent } = aiops; + getExplainLogRateSpikesComponent().then(setExplainLogRateSpikes); + } + }, []); + + return ( + <> + {ExplainLogRateSpikes !== null ? ( + <> + + + + + + ) : null} + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/aiops/index.ts b/x-pack/plugins/ml/public/application/aiops/index.ts new file mode 100644 index 000000000000000..fa47ae09822e2ab --- /dev/null +++ b/x-pack/plugins/ml/public/application/aiops/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ExplainLogRateSpikesPage } from './explain_log_rate_spikes'; diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 833a4fade128b15..50417aafab9b68d 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -82,6 +82,7 @@ const App: FC = ({ coreStart, deps, appMountParams }) => { maps: deps.maps, triggersActionsUi: deps.triggersActionsUi, dataVisualizer: deps.dataVisualizer, + aiops: deps.aiops, usageCollection: deps.usageCollection, fieldFormats: deps.fieldFormats, dashboard: deps.dashboard, @@ -135,6 +136,7 @@ export const renderApp = ( dashboard: deps.dashboard, maps: deps.maps, dataVisualizer: deps.dataVisualizer, + aiops: deps.aiops, dataViews: deps.data.dataViews, }); diff --git a/x-pack/plugins/ml/public/application/components/ml_page/ml_page.tsx b/x-pack/plugins/ml/public/application/components/ml_page/ml_page.tsx index 301939fb6fdbc2f..d41ca592554672f 100644 --- a/x-pack/plugins/ml/public/application/components/ml_page/ml_page.tsx +++ b/x-pack/plugins/ml/public/application/components/ml_page/ml_page.tsx @@ -71,7 +71,10 @@ export const MlPage: FC<{ pageDeps: PageDependencies }> = React.memo(({ pageDeps ); const routeList = useMemo( - () => Object.values(routes).map((routeFactory) => routeFactory(navigateToPath, basePath.get())), + () => + Object.values(routes) + .map((routeFactory) => routeFactory(navigateToPath, basePath.get())) + .filter((d) => !d.disabled), [] ); diff --git a/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx b/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx index e5c67de96f494d4..84474e85330d6b0 100644 --- a/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx +++ b/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx @@ -8,6 +8,7 @@ import { i18n } from '@kbn/i18n'; import type { EuiSideNavItemType } from '@elastic/eui'; import { useCallback, useMemo } from 'react'; +import { AIOPS_ENABLED } from '@kbn/aiops-plugin/common'; import type { MlLocatorParams } from '../../../../common/types/locator'; import { useUrlState } from '../../util/url_state'; import { useMlLocator, useNavigateToPath } from '../../contexts/kibana'; @@ -64,7 +65,7 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) { const tabsDefinition: Tab[] = useMemo((): Tab[] => { const disableLinks = mlFeaturesDisabled; - return [ + const mlTabs: Tab[] = [ { id: 'main_section', name: '', @@ -218,6 +219,28 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) { ], }, ]; + + if (AIOPS_ENABLED) { + mlTabs.push({ + id: 'aiops_section', + name: i18n.translate('xpack.ml.navMenu.aiopsTabLinkText', { + defaultMessage: 'AIOps', + }), + items: [ + { + id: 'explainlogratespikes', + pathId: ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES, + name: i18n.translate('xpack.ml.navMenu.explainLogRateSpikesLinkText', { + defaultMessage: 'Explain log rate spikes', + }), + disabled: disableLinks, + testSubj: 'mlMainTab explainLogRateSpikes', + }, + ], + }); + } + + return mlTabs; }, [mlFeaturesDisabled, canViewMlNodes]); const getTabItem: (tab: Tab) => EuiSideNavItemType = useCallback( diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts index bfb27e6d4dbbc58..fdfcd9106e8e071 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts @@ -16,6 +16,7 @@ import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; import type { EmbeddableStart } from '@kbn/embeddable-plugin/public'; import type { MapsStartApi } from '@kbn/maps-plugin/public'; import type { DataVisualizerPluginStart } from '@kbn/data-visualizer-plugin/public'; +import type { AiopsPluginStart } from '@kbn/aiops-plugin/public'; import type { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; import type { FieldFormatsRegistry } from '@kbn/field-formats-plugin/common'; import type { DashboardSetup } from '@kbn/dashboard-plugin/public'; @@ -32,6 +33,7 @@ interface StartPlugins { maps?: MapsStartApi; triggersActionsUi?: TriggersAndActionsUIPublicPluginStart; dataVisualizer?: DataVisualizerPluginStart; + aiops?: AiopsPluginStart; usageCollection?: UsageCollectionSetup; fieldFormats: FieldFormatsRegistry; dashboard: DashboardSetup; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx index cfdd2de06e0dbd3..d98940588f48fe0 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx @@ -9,7 +9,7 @@ import React, { FC, Fragment, useEffect, useState } from 'react'; import { EuiCallOut, EuiFormRow, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; import { isEqual } from 'lodash'; // @ts-ignore no declaration -import { LEFT_ALIGNMENT, CENTER_ALIGNMENT, SortableProperties } from '@elastic/eui/lib/services'; +import { LEFT_ALIGNMENT, SortableProperties } from '@elastic/eui/lib/services'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { FieldSelectionItem } from '../../../../common/analytics'; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js index 84908775a14a8f3..339925d3f16eeb6 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js @@ -83,7 +83,8 @@ describe('ExplorerChart', () => { ); // test if the loading indicator is shown - expect(wrapper.find('.ml-loading-indicator .euiLoadingChart')).toHaveLength(1); + // Added span because class appears twice with classNames and Emotion + expect(wrapper.find('.ml-loading-indicator span.euiLoadingChart')).toHaveLength(1); }); // For the following tests the directive needs to be rendered in the actual DOM, diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js index 890feb6efaf1872..3748a196e742d2e 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js @@ -86,7 +86,8 @@ describe('ExplorerChart', () => { ); // test if the loading indicator is shown - expect(wrapper.find('.ml-loading-indicator .euiLoadingChart')).toHaveLength(1); + // Added span because class appears twice with classNames and Emotion + expect(wrapper.find('.ml-loading-indicator span.euiLoadingChart')).toHaveLength(1); }); // For the following tests the directive needs to be rendered in the actual DOM, diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js index fd00058e6471307..13f7bb58a0f4487 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/extract_job_details.js @@ -99,6 +99,14 @@ export function extractJobDetails(job, basePath, refreshJobList) { return ['', ]; }), }; + if (job.alerting_rules) { + // remove the alerting_rules list from the general section + // so not to show it twice. + const i = general.items.findIndex((item) => item[0] === 'alerting_rules'); + if (i >= 0) { + general.items.splice(i, 1); + } + } const detectors = { id: 'detectors', diff --git a/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts b/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts index e563831d1637652..54aedb4a718574a 100644 --- a/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts +++ b/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts @@ -55,6 +55,13 @@ export const DATA_VISUALIZER_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ href: '/datavisualizer', }); +export const AIOPS_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ + text: i18n.translate('xpack.ml.aiopsBreadcrumbLabel', { + defaultMessage: 'AIOps', + }), + href: '/aiops', +}); + export const CREATE_JOB_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ text: i18n.translate('xpack.ml.createJobsBreadcrumbLabel', { defaultMessage: 'Create job', @@ -83,6 +90,7 @@ const breadcrumbs = { DATA_FRAME_ANALYTICS_BREADCRUMB, TRAINED_MODELS, DATA_VISUALIZER_BREADCRUMB, + AIOPS_BREADCRUMB, CREATE_JOB_BREADCRUMB, CALENDAR_MANAGEMENT_BREADCRUMB, FILTER_LISTS_BREADCRUMB, diff --git a/x-pack/plugins/ml/public/application/routing/ml_page_wrapper.tsx b/x-pack/plugins/ml/public/application/routing/ml_page_wrapper.tsx index f1674a12b77c6b0..4c2a6d0058edc57 100644 --- a/x-pack/plugins/ml/public/application/routing/ml_page_wrapper.tsx +++ b/x-pack/plugins/ml/public/application/routing/ml_page_wrapper.tsx @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import React, { FC } from 'react'; import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; diff --git a/x-pack/plugins/ml/public/application/routing/router.tsx b/x-pack/plugins/ml/public/application/routing/router.tsx index e4e7daa9ee0e118..a761bce2ce38a33 100644 --- a/x-pack/plugins/ml/public/application/routing/router.tsx +++ b/x-pack/plugins/ml/public/application/routing/router.tsx @@ -48,6 +48,7 @@ export interface MlRoute { enableDatePicker?: boolean; 'data-test-subj'?: string; actionMenu?: React.ReactNode; + disabled?: boolean; } export interface PageProps { diff --git a/x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.tsx b/x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.tsx new file mode 100644 index 000000000000000..ca670df258a6a6a --- /dev/null +++ b/x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.tsx @@ -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 React, { FC } from 'react'; +import { parse } from 'query-string'; + +import { i18n } from '@kbn/i18n'; + +import { AIOPS_ENABLED } from '@kbn/aiops-plugin/common'; + +import { NavigateToPath } from '../../../contexts/kibana'; + +import { MlRoute, PageLoader, PageProps } from '../../router'; +import { useResolver } from '../../use_resolver'; +import { ExplainLogRateSpikesPage as Page } from '../../../aiops/explain_log_rate_spikes'; + +import { checkBasicLicense } from '../../../license'; +import { checkGetJobsCapabilitiesResolver } from '../../../capabilities/check_capabilities'; +import { cacheDataViewsContract } from '../../../util/index_utils'; +import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; + +export const explainLogRateSpikesRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ + id: 'explain_log_rate_spikes', + path: '/aiops/explain_log_rate_spikes', + title: i18n.translate('xpack.ml.aiops.explainLogRateSpikes.docTitle', { + defaultMessage: 'Explain log rate spikes', + }), + render: (props, deps) => , + breadcrumbs: [ + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB', navigateToPath, basePath), + { + text: i18n.translate('xpack.ml.AiopsBreadcrumbs.explainLogRateSpikesLabel', { + defaultMessage: 'Explain log rate spikes', + }), + }, + ], + disabled: !AIOPS_ENABLED, +}); + +const PageWrapper: FC = ({ location, deps }) => { + const { redirectToMlAccessDeniedPage } = deps; + + const { index, savedSearchId }: Record = parse(location.search, { sort: false }); + const { context } = useResolver(index, savedSearchId, deps.config, deps.dataViewsContract, { + checkBasicLicense, + cacheDataViewsContract: () => cacheDataViewsContract(deps.dataViewsContract), + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + }); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/routing/routes/aiops/index.ts b/x-pack/plugins/ml/public/application/routing/routes/aiops/index.ts new file mode 100644 index 000000000000000..f2b192a4cd09768 --- /dev/null +++ b/x-pack/plugins/ml/public/application/routing/routes/aiops/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './explain_log_rate_spikes'; diff --git a/x-pack/plugins/ml/public/application/routing/routes/index.ts b/x-pack/plugins/ml/public/application/routing/routes/index.ts index 31a8d863e308675..12ddc39e0e23ea2 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/index.ts +++ b/x-pack/plugins/ml/public/application/routing/routes/index.ts @@ -11,6 +11,7 @@ export * from './new_job'; export * from './datavisualizer'; export * from './settings'; export * from './data_frame_analytics'; +export * from './aiops'; export { timeSeriesExplorerRouteFactory } from './timeseriesexplorer'; export * from './explorer'; export * from './access_denied'; diff --git a/x-pack/plugins/ml/public/application/routing/use_active_route.ts b/x-pack/plugins/ml/public/application/routing/use_active_route.ts index 925db8185c379bc..9183e45c3d0aed9 100644 --- a/x-pack/plugins/ml/public/application/routing/use_active_route.ts +++ b/x-pack/plugins/ml/public/application/routing/use_active_route.ts @@ -8,11 +8,21 @@ import { useLocation, useRouteMatch } from 'react-router-dom'; import { keyBy } from 'lodash'; import { useMemo } from 'react'; +import { useExecutionContext } from '@kbn/kibana-react-plugin/public'; +import { useMlKibana } from '../contexts/kibana'; import type { MlRoute } from './router'; +/** + * Provides an active route of the ML app. + * @param routesList + */ export const useActiveRoute = (routesList: MlRoute[]): MlRoute => { const { pathname } = useLocation(); + const { + services: { executionContext }, + } = useMlKibana(); + /** * Temp fix for routes with params. */ @@ -30,8 +40,14 @@ export const useActiveRoute = (routesList: MlRoute[]): MlRoute => { } // Remove trailing slash from the pathname const pathnameKey = pathname.replace(/\/$/, ''); - return routesMap[pathnameKey]; + return routesMap[pathnameKey] ?? routesMap['/overview']; }, [pathname]); - return activeRoute ?? routesMap['/overview']; + useExecutionContext(executionContext, { + name: 'Machine Learning', + type: 'application', + page: activeRoute?.path, + }); + + return activeRoute; }; diff --git a/x-pack/plugins/ml/public/application/services/job_service.d.ts b/x-pack/plugins/ml/public/application/services/job_service.d.ts index ad1cef009edbaa0..be0f03578692318 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.d.ts +++ b/x-pack/plugins/ml/public/application/services/job_service.d.ts @@ -5,8 +5,6 @@ * 2.0. */ -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; - import { TimeRange } from '@kbn/data-plugin/common/query/timefilter/types'; import { CombinedJob, Datafeed, Job } from '../../../common/types/anomaly_detection_jobs'; import { Calendar } from '../../../common/types/calendars'; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts index aefdf0ce6e43179..d15c500ddb9c441 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/trained_models.ts @@ -47,6 +47,10 @@ export interface InferenceStatsResponse { trained_model_stats: TrainedModelStat[]; } +export interface MlInferTrainedModelDeploymentResponse { + inference_results: estypes.MlInferTrainedModelDeploymentResponse[]; +} + /** * Service with APIs calls to perform inference operations. * @param httpService @@ -143,22 +147,13 @@ export function trainedModelsApiProvider(httpService: HttpService) { inferTrainedModel(modelId: string, payload: any, timeout?: string) { const body = JSON.stringify(payload); - return httpService.http({ + return httpService.http({ path: `${apiBasePath}/trained_models/infer/${modelId}`, method: 'POST', body, ...(timeout ? { query: { timeout } as HttpFetchQuery } : {}), }); }, - - ingestPipelineSimulate(payload: estypes.IngestSimulateRequest['body']) { - const body = JSON.stringify(payload); - return httpService.http({ - path: `${apiBasePath}/trained_models/ingest_pipeline_simulate`, - method: 'POST', - body, - }); - }, }; } diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx index 357015b05799653..992b217a0b2dfff 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/models_list.tsx @@ -53,7 +53,7 @@ import { DEPLOYMENT_STATE, TRAINED_MODEL_TYPE } from '../../../../common/constan import { getUserConfirmationProvider } from './force_stop_dialog'; import { MLSavedObjectsSpacesList } from '../../components/ml_saved_objects_spaces_list'; import { SavedObjectsWarning } from '../../components/saved_objects_warning'; -import { TestTrainedModelFlyout, isTestable } from './test_models'; +import { TestTrainedModelFlyout, isTestable, isTestEnabled } from './test_models'; type Stats = Omit; @@ -184,10 +184,8 @@ export const ModelsList: FC = ({ } } - // Need to fetch state for 3rd party models to enable/disable actions - await fetchModelsStats( - newItems.filter((v) => v.model_type.includes(TRAINED_MODEL_TYPE.PYTORCH)) - ); + // Need to fetch state for all models to enable/disable actions + await fetchModelsStats(newItems); setItems(newItems); @@ -484,9 +482,7 @@ export const ModelsList: FC = ({ isPrimary: true, available: isTestable, onClick: setShowTestFlyout, - enabled: (item) => - isPopulatedObject(item.stats?.deployment_stats) && - item.stats?.deployment_stats?.state === DEPLOYMENT_STATE.STARTED, + enabled: isTestEnabled, }, ] as Array>) ); diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/index.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/index.ts index da7c12c1c0c5845..533d8e2315a9e71 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/index.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/index.ts @@ -6,4 +6,4 @@ */ export { TestTrainedModelFlyout } from './test_flyout'; -export { isTestable } from './utils'; +export { isTestable, isTestEnabled } from './utils'; diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/index.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/index.ts index ef46c223f609e5f..c6cde8da394694a 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/index.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/index.ts @@ -10,9 +10,9 @@ import { TextClassificationInference, ZeroShotClassificationInference, FillMaskInference, + LangIdentInference, } from './text_classification'; import { TextEmbeddingInference } from './text_embedding'; -import { LangIdentInference } from './lang_ident'; export type InferrerType = | NerInference diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/inference_base.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/inference_base.ts index db27fb96e9c2a4b..e3b502a10f6cef9 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/inference_base.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/inference_base.ts @@ -9,8 +9,13 @@ import { BehaviorSubject } from 'rxjs'; import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { MLHttpFetchError } from '../../../../../../common/util/errors'; +import { SupportedPytorchTasksType } from '../../../../../../common/constants/trained_models'; import { trainedModelsApiProvider } from '../../../../services/ml_api_service/trained_models'; +export type InferenceType = + | SupportedPytorchTasksType + | keyof estypes.AggregationsInferenceConfigContainer; + const DEFAULT_INPUT_FIELD = 'text_field'; export type FormattedNerResponse = Array<{ @@ -32,6 +37,7 @@ export enum RUNNING_STATE { } export abstract class InferenceBase { + protected abstract inferenceType: InferenceType; protected readonly inputField: string; public inputText$ = new BehaviorSubject(''); public inferenceResult$ = new BehaviorSubject(null); @@ -67,4 +73,27 @@ export abstract class InferenceBase { protected abstract getOutputComponent(): JSX.Element; protected abstract infer(): Promise; + + protected getInferenceConfig(): estypes.AggregationsClassificationInferenceOptions | undefined { + return this.model.inference_config[ + this.inferenceType as keyof estypes.AggregationsInferenceConfigContainer + ]; + } + + protected getNumTopClassesConfig(defaultOverride = 5) { + const options: estypes.AggregationsClassificationInferenceOptions | undefined = + this.getInferenceConfig(); + + if (options?.num_top_classes !== undefined && options?.num_top_classes > 0) { + return {}; + } + + return { + inference_config: { + [this.inferenceType]: { + num_top_classes: defaultOverride, + }, + }, + }; + } } diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/lang_ident/lang_ident_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/lang_ident/lang_ident_inference.ts deleted file mode 100644 index 34893d7dc940255..000000000000000 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/lang_ident/lang_ident_inference.ts +++ /dev/null @@ -1,92 +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; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; - -import { InferenceBase, InferResponse } from '../inference_base'; -import { getGeneralInputComponent } from '../text_input'; -import { getLangIdentOutputComponent } from './lang_ident_output'; - -export type FormattedLangIdentResponse = Array<{ - className: string; - classProbability: number; - classScore: number; -}>; - -export type LangIdentResponse = InferResponse< - FormattedLangIdentResponse, - estypes.IngestSimulateResponse ->; - -export class LangIdentInference extends InferenceBase { - public async infer() { - try { - this.setRunning(); - const inputText = this.inputText$.value; - const payload: estypes.IngestSimulateRequest['body'] = { - pipeline: { - processors: [ - { - inference: { - model_id: this.model.model_id, - inference_config: { - // @ts-expect-error classification missing from type - classification: { - num_top_classes: 3, - }, - }, - field_mappings: { - contents: this.inputField, - }, - target_field: '_ml.lang_ident', - }, - }, - ], - }, - docs: [ - { - _source: { - contents: inputText, - }, - }, - ], - }; - const resp = await this.trainedModelsApi.ingestPipelineSimulate(payload); - if (resp.docs.length) { - const topClasses = resp.docs[0].doc?._source._ml?.lang_ident?.top_classes ?? []; - - const r: LangIdentResponse = { - response: topClasses.map((t: estypes.MlTopClassEntry) => ({ - className: t.class_name, - classProbability: t.class_probability, - classScore: t.class_score, - })), - rawResponse: resp, - inputText, - }; - this.inferenceResult$.next(r); - this.setFinished(); - return r; - } - const r: LangIdentResponse = { response: [], rawResponse: resp, inputText }; - this.inferenceResult$.next(r); - this.setFinished(); - return r; - } catch (error) { - this.setFinishedWithErrors(error); - throw error; - } - } - - public getInputComponent(): JSX.Element { - return getGeneralInputComponent(this); - } - - public getOutputComponent(): JSX.Element { - return getLangIdentOutputComponent(this); - } -} diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/ner/ner_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/ner/ner_inference.ts index 4125dcd02c6dbb0..13f07d8c88770b8 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/ner/ner_inference.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/ner/ner_inference.ts @@ -10,6 +10,8 @@ import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { InferenceBase, InferResponse } from '../inference_base'; import { getGeneralInputComponent } from '../text_input'; import { getNerOutputComponent } from './ner_output'; +import { MlInferTrainedModelDeploymentResponse } from '../../../../../services/ml_api_service/trained_models'; +import { SUPPORTED_PYTORCH_TASKS } from '../../../../../../../common/constants/trained_models'; export type FormattedNerResponse = Array<{ value: string; @@ -18,15 +20,17 @@ export type FormattedNerResponse = Array<{ export type NerResponse = InferResponse< FormattedNerResponse, - estypes.MlInferTrainedModelDeploymentResponse + MlInferTrainedModelDeploymentResponse >; export class NerInference extends InferenceBase { + protected inferenceType = SUPPORTED_PYTORCH_TASKS.NER; + public async infer() { try { this.setRunning(); - const inputText = this.inputText$.value; - const payload = { docs: { [this.inputField]: inputText } }; + const inputText = this.inputText$.getValue(); + const payload = { docs: [{ [this.inputField]: inputText }] }; const resp = await this.trainedModelsApi.inferTrainedModel( this.model.model_id, payload, @@ -56,8 +60,8 @@ export class NerInference extends InferenceBase { } } -function parseResponse(resp: estypes.MlInferTrainedModelDeploymentResponse): FormattedNerResponse { - const { predicted_value: predictedValue, entities } = resp; +function parseResponse(resp: MlInferTrainedModelDeploymentResponse): FormattedNerResponse { + const [{ predicted_value: predictedValue, entities }] = resp.inference_results; const splitWordsAndEntitiesRegex = /(\[.*?\]\(.*?&.*?\))/; const matchEntityRegex = /(\[.*?\])\((.*?)&(.*?)\)/; if (predictedValue === undefined || entities === undefined) { diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/raw_output.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/raw_output.tsx index 0031aafa8144390..4a82dcb82aa65ef 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/raw_output.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/raw_output.tsx @@ -14,14 +14,9 @@ import type { InferrerType } from '.'; import { NerResponse } from './ner'; import { TextClassificationResponse } from './text_classification'; import { TextEmbeddingResponse } from './text_embedding'; -import { LangIdentResponse } from './lang_ident'; import { RUNNING_STATE } from './inference_base'; -type InferenceResponse = - | LangIdentResponse - | NerResponse - | TextClassificationResponse - | TextEmbeddingResponse; +type InferenceResponse = NerResponse | TextClassificationResponse | TextEmbeddingResponse; export const RawOutput: FC<{ inferrer: InferrerType; diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/common.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/common.ts index d360711995f9823..ab136900c7d1ef6 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/common.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/common.ts @@ -10,12 +10,14 @@ import { InferResponse } from '../inference_base'; const PROBABILITY_SIG_FIGS = 3; export interface RawTextClassificationResponse { - predicted_value: string; - prediction_probability: number; - top_classes?: Array<{ - class_name: string; - class_probability: number; - class_score: number; + inference_results: Array<{ + predicted_value: string; + prediction_probability: number; + top_classes?: Array<{ + class_name: string; + class_probability: number; + class_score: number; + }>; }>; } @@ -34,21 +36,24 @@ export function processResponse( model: estypes.MlTrainedModelConfig, inputText: string ): TextClassificationResponse { + const { + inference_results: [inferenceResults], + } = resp; const labels: string[] = // @ts-expect-error inference config is wrong model.inference_config.text_classification?.classification_labels ?? []; let formattedResponse = [ { - value: resp.predicted_value, - predictionProbability: resp.prediction_probability, + value: inferenceResults.predicted_value, + predictionProbability: inferenceResults.prediction_probability, }, ]; - if (resp.top_classes !== undefined) { + if (inferenceResults.top_classes !== undefined) { // if num_top_classes has been specified in the model, // base the returned results on this list - formattedResponse = resp.top_classes.map((topClass) => { + formattedResponse = inferenceResults.top_classes.map((topClass) => { return { value: topClass.class_name, predictionProbability: topClass.class_probability, @@ -59,9 +64,9 @@ export function processResponse( // we can safely assume the non-top value and return two results formattedResponse = labels.map((value) => { const predictionProbability = - resp.predicted_value === value - ? resp.prediction_probability - : 1 - resp.prediction_probability; + inferenceResults.predicted_value === value + ? inferenceResults.prediction_probability + : 1 - inferenceResults.prediction_probability; return { value, diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_inference.ts index c8c993785dac20e..bb4feaffffb3888 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_inference.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_inference.ts @@ -11,20 +11,20 @@ import type { TextClassificationResponse, RawTextClassificationResponse } from ' import { processResponse } from './common'; import { getGeneralInputComponent } from '../text_input'; import { getFillMaskOutputComponent } from './fill_mask_output'; +import { SUPPORTED_PYTORCH_TASKS } from '../../../../../../../common/constants/trained_models'; const MASK = '[MASK]'; export class FillMaskInference extends InferenceBase { - // @ts-expect-error model type is wrong - private numTopClasses = this.model.inference_config?.fill_mask?.num_top_classes || 5; + protected inferenceType = SUPPORTED_PYTORCH_TASKS.FILL_MASK; public async infer() { try { this.setRunning(); - const inputText = this.inputText$.value; + const inputText = this.inputText$.getValue(); const payload = { - docs: { [this.inputField]: inputText }, - inference_config: { fill_mask: { num_top_classes: this.numTopClasses } }, + docs: [{ [this.inputField]: inputText }], + ...this.getNumTopClassesConfig(), }; const resp = (await this.trainedModelsApi.inferTrainedModel( this.model.model_id, diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_output.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_output.tsx index dee08392aad09c4..62a5a957f8a3896 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_output.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/fill_mask_output.tsx @@ -7,9 +7,10 @@ import React, { FC, useMemo } from 'react'; import useObservable from 'react-use/lib/useObservable'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiProgress, EuiTitle } from '@elastic/eui'; +import { EuiSpacer, EuiTitle } from '@elastic/eui'; import type { FillMaskInference } from './fill_mask_inference'; +import { TextClassificationOutput } from './text_classification_output'; export const getFillMaskOutputComponent = (inferrer: FillMaskInference) => ( @@ -32,20 +33,7 @@ const FillMaskOutput: FC<{ - - {result.response.map(({ value, predictionProbability }) => ( - <> - - - - <> - {value} - {predictionProbability} - - - - - ))} + ); }; diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/index.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/index.ts index 4eeef37519ff27e..5274333a235cd67 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/index.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/index.ts @@ -15,3 +15,6 @@ export { getZeroShotClassificationInput } from './zero_shot_classification_input export { FillMaskInference } from './fill_mask_inference'; export { getFillMaskOutputComponent } from './fill_mask_output'; + +export { LangIdentInference } from './lang_ident_inference'; +export { getLangIdentOutputComponent } from './lang_ident_output'; diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/lang_ident/lang_codes.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_codes.ts similarity index 100% rename from x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/lang_ident/lang_codes.ts rename to x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_codes.ts diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_inference.ts new file mode 100644 index 000000000000000..a56d4a3598a66dd --- /dev/null +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_inference.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { InferenceBase, InferenceType } from '../inference_base'; +import { processResponse } from './common'; +import { getGeneralInputComponent } from '../text_input'; +import { getLangIdentOutputComponent } from './lang_ident_output'; +import type { TextClassificationResponse, RawTextClassificationResponse } from './common'; + +export class LangIdentInference extends InferenceBase { + protected inferenceType: InferenceType = 'classification'; + + public async infer() { + try { + this.setRunning(); + const inputText = this.inputText$.getValue(); + const payload = { + docs: [{ [this.inputField]: inputText }], + ...this.getNumTopClassesConfig(), + }; + const resp = (await this.trainedModelsApi.inferTrainedModel( + this.model.model_id, + payload, + '30s' + )) as unknown as RawTextClassificationResponse; + + const processedResponse: TextClassificationResponse = processResponse( + resp, + this.model, + inputText + ); + this.inferenceResult$.next(processedResponse); + this.setFinished(); + + return processedResponse; + } catch (error) { + this.setFinishedWithErrors(error); + throw error; + } + } + + public getInputComponent(): JSX.Element { + return getGeneralInputComponent(this); + } + + public getOutputComponent(): JSX.Element { + return getLangIdentOutputComponent(this); + } +} diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/lang_ident/lang_ident_output.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_output.tsx similarity index 51% rename from x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/lang_ident/lang_ident_output.tsx rename to x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_output.tsx index 584e367aac78491..a4f2a6e2884e1c4 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/lang_ident/lang_ident_output.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/lang_ident_output.tsx @@ -8,12 +8,11 @@ import React, { FC } from 'react'; import useObservable from 'react-use/lib/useObservable'; import { i18n } from '@kbn/i18n'; -import { EuiSpacer, EuiBasicTable, EuiTitle } from '@elastic/eui'; +import { EuiSpacer, EuiTitle } from '@elastic/eui'; import type { LangIdentInference } from './lang_ident_inference'; import { getLanguage } from './lang_codes'; - -const PROBABILITY_SIG_FIGS = 3; +import { getTextClassificationOutputComponent } from './text_classification_output'; export const getLangIdentOutputComponent = (inferrer: LangIdentInference) => ( @@ -25,48 +24,7 @@ const LangIdentOutput: FC<{ inferrer: LangIdentInference }> = ({ inferrer }) => return null; } - const lang = getLanguage(result.response[0].className); - - const items = result.response.map(({ className, classProbability }, i) => { - return { - noa: `${i + 1}`, - className: getLanguage(className), - classProbability: `${Number(classProbability).toPrecision(PROBABILITY_SIG_FIGS)}`, - }; - }); - - const columns = [ - { - field: 'noa', - name: '#', - width: '5%', - truncateText: false, - isExpander: false, - }, - { - field: 'className', - name: i18n.translate( - 'xpack.ml.trainedModels.testModelsFlyout.langIdent.output.language_title', - { - defaultMessage: 'Language', - } - ), - width: '30%', - truncateText: false, - isExpander: false, - }, - { - field: 'classProbability', - name: i18n.translate( - 'xpack.ml.trainedModels.testModelsFlyout.langIdent.output.probability_title', - { - defaultMessage: 'Probability', - } - ), - truncateText: false, - isExpander: false, - }, - ]; + const lang = getLanguage(result.response[0].value); const title = lang !== 'unknown' @@ -76,7 +34,7 @@ const LangIdentOutput: FC<{ inferrer: LangIdentInference }> = ({ inferrer }) => }) : i18n.translate('xpack.ml.trainedModels.testModelsFlyout.langIdent.output.titleUnknown', { defaultMessage: 'Language code unknown: {code}', - values: { code: result.response[0].className }, + values: { code: result.response[0].value }, }); return ( @@ -86,7 +44,7 @@ const LangIdentOutput: FC<{ inferrer: LangIdentInference }> = ({ inferrer }) => - + {getTextClassificationOutputComponent(inferrer)} ); }; diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/text_classification_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/text_classification_inference.ts index 1bebdc6ac82e481..33aa1c0f1c86dfe 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/text_classification_inference.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/text_classification_inference.ts @@ -10,18 +10,18 @@ import { processResponse } from './common'; import type { TextClassificationResponse, RawTextClassificationResponse } from './common'; import { getGeneralInputComponent } from '../text_input'; import { getTextClassificationOutputComponent } from './text_classification_output'; +import { SUPPORTED_PYTORCH_TASKS } from '../../../../../../../common/constants/trained_models'; export class TextClassificationInference extends InferenceBase { - // @ts-expect-error model type is wrong - private numTopClasses = this.model.inference_config?.text_classification?.num_top_classes || 5; + protected inferenceType = SUPPORTED_PYTORCH_TASKS.TEXT_CLASSIFICATION; public async infer() { try { this.setRunning(); - const inputText = this.inputText$.value; + const inputText = this.inputText$.getValue(); const payload = { - docs: { [this.inputField]: inputText }, - inference_config: { text_classification: { num_top_classes: this.numTopClasses } }, + docs: [{ [this.inputField]: inputText }], + ...this.getNumTopClassesConfig(), }; const resp = (await this.trainedModelsApi.inferTrainedModel( this.model.model_id, diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/text_classification_output.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/text_classification_output.tsx index faeed456d1a2198..69ecf621510afe5 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/text_classification_output.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/text_classification_output.tsx @@ -9,14 +9,27 @@ import React, { FC } from 'react'; import useObservable from 'react-use/lib/useObservable'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiProgress } from '@elastic/eui'; -import type { TextClassificationInference, ZeroShotClassificationInference } from '.'; +import type { + TextClassificationInference, + ZeroShotClassificationInference, + FillMaskInference, + LangIdentInference, +} from '.'; export const getTextClassificationOutputComponent = ( - inferrer: TextClassificationInference | ZeroShotClassificationInference + inferrer: + | TextClassificationInference + | ZeroShotClassificationInference + | FillMaskInference + | LangIdentInference ) => ; -const TextClassificationOutput: FC<{ - inferrer: TextClassificationInference | ZeroShotClassificationInference; +export const TextClassificationOutput: FC<{ + inferrer: + | TextClassificationInference + | ZeroShotClassificationInference + | FillMaskInference + | LangIdentInference; }> = ({ inferrer }) => { const result = useObservable(inferrer.inferenceResult$); if (!result) { diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/zero_shot_classification_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/zero_shot_classification_inference.ts index b8897c439ece87e..9a093cc44c17090 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/zero_shot_classification_inference.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_classification/zero_shot_classification_inference.ts @@ -12,20 +12,23 @@ import type { TextClassificationResponse, RawTextClassificationResponse } from ' import { getZeroShotClassificationInput } from './zero_shot_classification_input'; import { getTextClassificationOutputComponent } from './text_classification_output'; +import { SUPPORTED_PYTORCH_TASKS } from '../../../../../../../common/constants/trained_models'; export class ZeroShotClassificationInference extends InferenceBase { + protected inferenceType = SUPPORTED_PYTORCH_TASKS.ZERO_SHOT_CLASSIFICATION; + public labelsText$ = new BehaviorSubject(''); public async infer() { try { this.setRunning(); - const inputText = this.inputText$.value; + const inputText = this.inputText$.getValue(); const labelsText = this.labelsText$.value; const inputLabels = labelsText?.split(',').map((l) => l.trim()); const payload = { - docs: { [this.inputField]: inputText }, + docs: [{ [this.inputField]: inputText }], inference_config: { - zero_shot_classification: { + [this.inferenceType]: { labels: inputLabels, multi_label: false, }, diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_embedding/text_embedding_inference.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_embedding/text_embedding_inference.ts index ffddc81938f19ed..3613f66d3ed93f1 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_embedding/text_embedding_inference.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/models/text_embedding/text_embedding_inference.ts @@ -10,9 +10,10 @@ import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { InferenceBase, InferResponse } from '../inference_base'; import { getGeneralInputComponent } from '../text_input'; import { getTextEmbeddingOutputComponent } from './text_embedding_output'; +import { SUPPORTED_PYTORCH_TASKS } from '../../../../../../../common/constants/trained_models'; export interface RawTextEmbeddingResponse { - predicted_value: number[]; + inference_results: [{ predicted_value: number[] }]; } export interface FormattedTextEmbeddingResponse { @@ -25,12 +26,14 @@ export type TextEmbeddingResponse = InferResponse< >; export class TextEmbeddingInference extends InferenceBase { + protected inferenceType = SUPPORTED_PYTORCH_TASKS.TEXT_EMBEDDING; + public async infer() { try { this.setRunning(); - const inputText = this.inputText$.value; + const inputText = this.inputText$.getValue(); const payload = { - docs: { [this.inputField]: inputText }, + docs: [{ [this.inputField]: inputText }], }; const resp = (await this.trainedModelsApi.inferTrainedModel( this.model.model_id, @@ -63,6 +66,6 @@ function processResponse( model: estypes.MlTrainedModelConfig, inputText: string ) { - const predictedValue = resp.predicted_value; + const predictedValue = resp.inference_results[0].predicted_value; return { response: { predictedValue }, rawResponse: resp, inputText }; } diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/selected_model.tsx b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/selected_model.tsx index 50a5f9615d12683..816166c5cbcbf2f 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/selected_model.tsx +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/selected_model.tsx @@ -10,12 +10,11 @@ import React, { FC } from 'react'; import { NerInference } from './models/ner'; -import { LangIdentInference } from './models/lang_ident'; - import { TextClassificationInference, FillMaskInference, ZeroShotClassificationInference, + LangIdentInference, } from './models/text_classification'; import { TextEmbeddingInference } from './models/text_embedding'; diff --git a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/utils.ts b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/utils.ts index 99a0f76891c3004..3ac6ec77f576af5 100644 --- a/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/utils.ts +++ b/x-pack/plugins/ml/public/application/trained_models/models_management/test_models/utils.ts @@ -5,26 +5,37 @@ * 2.0. */ -import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { TRAINED_MODEL_TYPE, + DEPLOYMENT_STATE, SUPPORTED_PYTORCH_TASKS, } from '../../../../../common/constants/trained_models'; import type { SupportedPytorchTasksType } from '../../../../../common/constants/trained_models'; +import type { ModelItem } from '../models_list'; + +import { isPopulatedObject } from '../../../../../common'; const PYTORCH_TYPES = Object.values(SUPPORTED_PYTORCH_TASKS); -export function isTestable(model: estypes.MlTrainedModelConfig) { +export function isTestable(modelItem: ModelItem) { if ( - model.model_type === TRAINED_MODEL_TYPE.PYTORCH && - PYTORCH_TYPES.includes(Object.keys(model.inference_config)[0] as SupportedPytorchTasksType) + modelItem.model_type === TRAINED_MODEL_TYPE.PYTORCH && + PYTORCH_TYPES.includes(Object.keys(modelItem.inference_config)[0] as SupportedPytorchTasksType) ) { return true; } - if (model.model_type === TRAINED_MODEL_TYPE.LANG_IDENT) { + if (modelItem.model_type === TRAINED_MODEL_TYPE.LANG_IDENT) { return true; } return false; } + +export function isTestEnabled(modelItem: ModelItem) { + return ( + isPopulatedObject(modelItem.stats?.deployment_stats) === false || + (isPopulatedObject(modelItem.stats?.deployment_stats) && + modelItem.stats?.deployment_stats?.state === DEPLOYMENT_STATE.STARTED) + ); +} diff --git a/x-pack/plugins/ml/public/application/util/dependency_cache.ts b/x-pack/plugins/ml/public/application/util/dependency_cache.ts index 3680f8b63b0c96a..00895cdb3990e88 100644 --- a/x-pack/plugins/ml/public/application/util/dependency_cache.ts +++ b/x-pack/plugins/ml/public/application/util/dependency_cache.ts @@ -27,6 +27,7 @@ import type { DataViewsContract } from '@kbn/data-views-plugin/public'; import type { SecurityPluginSetup } from '@kbn/security-plugin/public'; import type { MapsStartApi } from '@kbn/maps-plugin/public'; import type { DataVisualizerPluginStart } from '@kbn/data-visualizer-plugin/public'; +import type { AiopsPluginStart } from '@kbn/aiops-plugin/public'; export interface DependencyCache { timefilter: DataPublicPluginSetup['query']['timefilter'] | null; @@ -48,6 +49,7 @@ export interface DependencyCache { dashboard: DashboardStart | null; maps: MapsStartApi | null; dataVisualizer: DataVisualizerPluginStart | null; + aiops: AiopsPluginStart | null; dataViews: DataViewsContract | null; } @@ -71,6 +73,7 @@ const cache: DependencyCache = { dashboard: null, maps: null, dataVisualizer: null, + aiops: null, dataViews: null, }; @@ -93,6 +96,7 @@ export function setDependencyCache(deps: Partial) { cache.i18n = deps.i18n || null; cache.dashboard = deps.dashboard || null; cache.dataVisualizer = deps.dataVisualizer || null; + cache.aiops = deps.aiops || null; cache.dataViews = deps.dataViews || null; } diff --git a/x-pack/plugins/ml/public/application/util/time_buckets.d.ts b/x-pack/plugins/ml/public/application/util/time_buckets.d.ts index 8d8618aa7c97b8f..9a5410918a09948 100644 --- a/x-pack/plugins/ml/public/application/util/time_buckets.d.ts +++ b/x-pack/plugins/ml/public/application/util/time_buckets.d.ts @@ -6,7 +6,6 @@ */ import { Moment } from 'moment'; -import { UI_SETTINGS } from '@kbn/data-plugin/public'; export interface TimeRangeBounds { min?: Moment; diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.tsx index 9f31f5777f9de23..85350629263e4a3 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_charts/embeddable_anomaly_charts_container.tsx @@ -11,6 +11,7 @@ import { Observable } from 'rxjs'; import { FormattedMessage } from '@kbn/i18n-react'; import { throttle } from 'lodash'; import { UI_SETTINGS } from '@kbn/data-plugin/common'; +import { useEmbeddableExecutionContext } from '../common/use_embeddable_execution_context'; import { useAnomalyChartsInputResolver } from './use_anomaly_charts_input_resolver'; import type { IAnomalyChartsEmbeddable } from './anomaly_charts_embeddable'; import type { @@ -27,6 +28,7 @@ import { ANOMALY_THRESHOLD } from '../../../common'; import { TimeBuckets } from '../../application/util/time_buckets'; import { EXPLORER_ENTITY_FIELD_SELECTION_TRIGGER } from '../../ui_actions/triggers'; import { MlLocatorParams } from '../../../common/types/locator'; +import { ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE } from '..'; const RESIZE_THROTTLE_TIME_MS = 500; @@ -55,6 +57,13 @@ export const EmbeddableAnomalyChartsContainer: FC { + useEmbeddableExecutionContext( + services[0].executionContext, + embeddableInput, + ANOMALY_EXPLORER_CHARTS_EMBEDDABLE_TYPE, + id + ); + const [chartWidth, setChartWidth] = useState(0); const [severity, setSeverity] = useState( optionValueToThreshold( diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx index 06c400481491a46..c354057d971bb7d 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/embeddable_swim_lane_container.tsx @@ -11,6 +11,7 @@ import { Observable } from 'rxjs'; import { CoreStart } from '@kbn/core/public'; import { FormattedMessage } from '@kbn/i18n-react'; +import { useEmbeddableExecutionContext } from '../common/use_embeddable_execution_context'; import { IAnomalySwimlaneEmbeddable } from './anomaly_swimlane_embeddable'; import { useSwimlaneInputResolver } from './swimlane_input_resolver'; import { SwimlaneType } from '../../application/explorer/explorer_constants'; @@ -22,6 +23,7 @@ import { AppStateSelectedCells } from '../../application/explorer/explorer_utils import { MlDependencies } from '../../application/app'; import { SWIM_LANE_SELECTION_TRIGGER } from '../../ui_actions'; import { + ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, AnomalySwimlaneEmbeddableInput, AnomalySwimlaneEmbeddableOutput, AnomalySwimlaneServices, @@ -52,6 +54,13 @@ export const EmbeddableSwimLaneContainer: FC = ( onLoading, onError, }) => { + useEmbeddableExecutionContext( + services[0].executionContext, + embeddableInput, + ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, + id + ); + const [chartWidth, setChartWidth] = useState(0); const [fromPage, setFromPage] = useState(1); diff --git a/x-pack/plugins/ml/public/embeddables/common/use_embeddable_execution_context.ts b/x-pack/plugins/ml/public/embeddables/common/use_embeddable_execution_context.ts new file mode 100644 index 000000000000000..68306c54c859029 --- /dev/null +++ b/x-pack/plugins/ml/public/embeddables/common/use_embeddable_execution_context.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import useObservable from 'react-use/lib/useObservable'; +import { map } from 'rxjs/operators'; +import { KibanaExecutionContext } from '@kbn/core/types'; +import { useMemo } from 'react'; +import { useExecutionContext } from '@kbn/kibana-react-plugin/public'; +import type { Observable } from 'rxjs'; +import type { EmbeddableInput } from '@kbn/embeddable-plugin/common'; +import { ExecutionContextStart } from '@kbn/core/public'; + +/** + * Use execution context for ML embeddables. + * @param executionContext + * @param embeddableInput$ + * @param embeddableType + * @param id + */ +export function useEmbeddableExecutionContext( + executionContext: ExecutionContextStart, + embeddableInput$: Observable, + embeddableType: string, + id: string +) { + const parentExecutionContext = useObservable( + embeddableInput$.pipe(map((v) => v.executionContext)) + ); + + const embeddableExecutionContext: KibanaExecutionContext = useMemo(() => { + const child: KibanaExecutionContext = { + type: 'visualization', + name: embeddableType, + id, + }; + + return { + ...parentExecutionContext, + child, + }; + }, [parentExecutionContext, id]); + + useExecutionContext(executionContext, embeddableExecutionContext); +} diff --git a/x-pack/plugins/ml/public/locator/ml_locator.ts b/x-pack/plugins/ml/public/locator/ml_locator.ts index b1ea2549c334725..01d63aa0ebf3ff1 100644 --- a/x-pack/plugins/ml/public/locator/ml_locator.ts +++ b/x-pack/plugins/ml/public/locator/ml_locator.ts @@ -83,6 +83,8 @@ export class MlLocatorDefinition implements LocatorDefinition { case ML_PAGES.DATA_VISUALIZER_FILE: case ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER: case ML_PAGES.DATA_VISUALIZER_INDEX_SELECT: + case ML_PAGES.AIOPS: + case ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES: case ML_PAGES.OVERVIEW: case ML_PAGES.SETTINGS: case ML_PAGES.FILTER_LISTS_MANAGE: diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 1ef7c73d2189a7b..79f386d521da11a 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -37,6 +37,7 @@ import { TriggersAndActionsUIPublicPluginStart, } from '@kbn/triggers-actions-ui-plugin/public'; import type { DataVisualizerPluginStart } from '@kbn/data-visualizer-plugin/public'; +import type { AiopsPluginStart } from '@kbn/aiops-plugin/public'; import type { PluginSetupContract as AlertingSetup } from '@kbn/alerting-plugin/public'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; import type { FieldFormatsSetup, FieldFormatsStart } from '@kbn/field-formats-plugin/public'; @@ -59,6 +60,7 @@ export interface MlStartDependencies { maps?: MapsStartApi; triggersActionsUi?: TriggersAndActionsUIPublicPluginStart; dataVisualizer: DataVisualizerPluginStart; + aiops: AiopsPluginStart; fieldFormats: FieldFormatsStart; dashboard: DashboardStart; charts: ChartsPluginStart; @@ -125,6 +127,7 @@ export class MlPlugin implements Plugin { kibanaVersion, triggersActionsUi: pluginsStart.triggersActionsUi, dataVisualizer: pluginsStart.dataVisualizer, + aiops: pluginsStart.aiops, usageCollection: pluginsSetup.usageCollection, fieldFormats: pluginsStart.fieldFormats, }, diff --git a/x-pack/plugins/ml/readme.md b/x-pack/plugins/ml/readme.md index 7bd1a1a221edd5b..a29e976f12c462b 100644 --- a/x-pack/plugins/ml/readme.md +++ b/x-pack/plugins/ml/readme.md @@ -104,42 +104,49 @@ Run the following commands from the `x-pack` directory and use separate terminal for test server and test runner. The test server command starts an Elasticsearch and Kibana instance that the tests will be run against. -1. Functional UI tests with `Trial` license (default config): - - node scripts/functional_tests_server.js - node scripts/functional_test_runner.js --include-tag mlqa - - ML functional `Trial` license tests are located in `x-pack/test/functional/apps/ml`. - +Functional tests are broken up into independent groups with their own configuration. +Test server and runner need to be pointed to the configuration to run. The basic +commands are + + node scripts/functional_tests_server.js --config PATH_TO_CONFIG + node scripts/functional_test_runner.js --config PATH_TO_CONFIG + +With PATH_TO_CONFIG and other options as follows. + +1. Functional UI tests with `Trial` license: + + Group | PATH_TO_CONFIG + ----- | -------------- + anomaly detection | `test/functional/apps/ml/anomaly_detection/config.ts` + data frame analytics | `test/functional/apps/ml/data_frame_analytics/config.ts` + data visualizer | `test/functional/apps/ml/data_visualizer/config.ts` + permissions | `test/functional/apps/ml/permissions/config.ts` + stack management jobs | `test/functional/apps/ml/stack_management_jobs/config.ts` + short tests | `test/functional/apps/ml/short_tests/config.ts` + + The `short tests` group contains tests for page navigation, model management, + feature controls, settings and embeddables. Test files for each group are located + in the directory of their configuration file. + 1. Functional UI tests with `Basic` license: - node scripts/functional_tests_server.js --config test/functional_basic/config.ts - node scripts/functional_test_runner.js --config test/functional_basic/config.ts --include-tag mlqa - - ML functional `Basic` license tests are located in `x-pack/test/functional_basic/apps/ml`. + - PATH_TO_CONFIG: `test/functional_basic/config.ts` + - Add `--include-tag ml` to the test runner command + - Tests are located in `x-pack/test/functional_basic/apps/ml` 1. API integration tests with `Trial` license: - node scripts/functional_tests_server.js - node scripts/functional_test_runner.js --config test/api_integration/config.ts --include-tag mlqa - - ML API integration `Trial` license tests are located in `x-pack/test/api_integration/apis/ml`. - -1. API integration tests with `Basic` license: - - node scripts/functional_tests_server.js --config test/api_integration_basic/config.ts - node scripts/functional_test_runner.js --config test/api_integration_basic/config.ts --include-tag mlqa - - ML API integration `Basic` license tests are located in `x-pack/test/api_integration_basic/apis/ml`. + - PATH_TO_CONFIG: `test/api_integration/config.ts` + - Add `--include-tag ml` to the test runner command + - Tests are located in `x-pack/test/api_integration/apis/ml` 1. Accessibility tests: We maintain a suite of accessibility tests (you may see them referred to elsewhere as `a11y` tests). These tests render each of our pages and ensure that the inputs and other elements contain the attributes necessary to ensure all users are able to make use of ML (for example, users relying on screen readers). - node scripts/functional_tests_server --config test/accessibility/config.ts - node scripts/functional_test_runner.js --config test/accessibility/config.ts --grep=ml - - ML accessibility tests are located in `x-pack/test/accessibility/apps`. + - PATH_TO_CONFIG: `test/accessibility/config.ts` + - Add `--grep=ml` to the test runner command + - Tests are located in `x-pack/test/accessibility/apps` ## Generating docs screenshots @@ -151,7 +158,7 @@ for test server and test runner. The test server command starts an Elasticsearch and Kibana instance that the tests will be run against. node scripts/functional_tests_server.js --config test/screenshot_creation/config.ts - node scripts/functional_test_runner.js --config test/screenshot_creation/config.ts --include-tag mlqa + node scripts/functional_test_runner.js --config test/screenshot_creation/config.ts --include-tag ml The generated screenshots are stored in `x-pack/test/functional/screenshots/session/ml_docs`. ML screenshot generation tests are located in `x-pack/test/screenshot_creation/apps/ml_docs`. diff --git a/x-pack/plugins/ml/server/index.ts b/x-pack/plugins/ml/server/index.ts index 786920ef5e46e38..8a1cfb9590402ff 100644 --- a/x-pack/plugins/ml/server/index.ts +++ b/x-pack/plugins/ml/server/index.ts @@ -14,6 +14,8 @@ export type { AnomalyResultType as MlAnomalyResultType, DatafeedStats as MlDatafeedStats, Job as MlJob, + MlSummaryJob, + SummaryJobState as MlSummaryJobState, } from './shared'; export { UnknownMLCapabilitiesError, diff --git a/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts b/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts index f322f09356c906c..808c77d4d6ea529 100644 --- a/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts +++ b/x-pack/plugins/ml/server/lib/ml_client/ml_client.ts @@ -496,7 +496,28 @@ export function getMlClient( }, async inferTrainedModelDeployment(...p: Parameters) { await modelIdsCheck(p); - return mlClient.inferTrainedModelDeployment(...p); + // Temporary workaround for the incorrect inferTrainedModelDeployment function in the esclient + if ( + // @ts-expect-error TS complains it's always false + p.length === 0 || + p[0] === undefined + ) { + // Temporary generic error message. This should never be triggered + // but is added for type correctness below + throw new Error('Incorrect arguments supplied'); + } + // @ts-expect-error body doesn't exist in the type + const { model_id: id, body, query: querystring } = p[0]; + + return client.asInternalUser.transport.request( + { + method: 'POST', + path: `/_ml/trained_models/${id}/_infer`, + body, + querystring, + }, + p[1] + ); }, async info(...p: Parameters) { return mlClient.info(...p); diff --git a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.d.ts b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.d.ts index 30d9c76cd9dcb95..0216f882196377c 100644 --- a/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.d.ts +++ b/x-pack/plugins/ml/server/models/bucket_span_estimator/bucket_span_estimator.d.ts @@ -6,9 +6,6 @@ */ import { IScopedClusterClient } from '@kbn/core/server'; -import { ES_AGGREGATION } from '../../../common/constants/aggregation_types'; -import { RuntimeMappings } from '../../../common/types/fields'; -import { IndicesOptions } from '../../../common/types/anomaly_detection_jobs'; import { BucketSpanEstimatorData } from '../../../common/types/job_service'; export function estimateBucketSpanFactory({ diff --git a/x-pack/plugins/ml/server/routes/apidoc.json b/x-pack/plugins/ml/server/routes/apidoc.json index ac09aee7fcbb983..e36e51aded4baa7 100644 --- a/x-pack/plugins/ml/server/routes/apidoc.json +++ b/x-pack/plugins/ml/server/routes/apidoc.json @@ -172,7 +172,6 @@ "PutTrainedModel", "DeleteTrainedModel", "InferTrainedModelDeployment", - "IngestPipelineSimulate", "Alerting", "PreviewAlert" diff --git a/x-pack/plugins/ml/server/routes/schemas/inference_schema.ts b/x-pack/plugins/ml/server/routes/schemas/inference_schema.ts index ea18930cdec365f..2273bb48c4f322a 100644 --- a/x-pack/plugins/ml/server/routes/schemas/inference_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/inference_schema.ts @@ -31,17 +31,6 @@ export const putTrainedModelQuerySchema = schema.object({ defer_definition_decompression: schema.maybe(schema.boolean()), }); -export const pipelineSchema = schema.object({ - pipeline: schema.object({ - description: schema.maybe(schema.string()), - processors: schema.arrayOf(schema.recordOf(schema.string(), schema.any())), - version: schema.maybe(schema.number()), - on_failure: schema.maybe(schema.arrayOf(schema.recordOf(schema.string(), schema.any()))), - }), - docs: schema.arrayOf(schema.recordOf(schema.string(), schema.any())), - verbose: schema.maybe(schema.boolean()), -}); - export const inferTrainedModelQuery = schema.object({ timeout: schema.maybe(schema.string()) }); export const inferTrainedModelBody = schema.object({ docs: schema.any(), diff --git a/x-pack/plugins/ml/server/routes/trained_models.ts b/x-pack/plugins/ml/server/routes/trained_models.ts index 731d159032d4587..4c8893b3144ead0 100644 --- a/x-pack/plugins/ml/server/routes/trained_models.ts +++ b/x-pack/plugins/ml/server/routes/trained_models.ts @@ -5,7 +5,6 @@ * 2.0. */ -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { schema } from '@kbn/config-schema'; import { RouteInitialization } from '../types'; import { wrapError } from '../client/error_wrapper'; @@ -14,7 +13,6 @@ import { modelIdSchema, optionalModelIdSchema, putTrainedModelQuerySchema, - pipelineSchema, inferTrainedModelQuery, inferTrainedModelBody, } from './schemas/inference_schema'; @@ -397,41 +395,4 @@ export function trainedModelsRoutes({ router, routeGuard }: RouteInitialization) } }) ); - - /** - * @apiGroup TrainedModels - * - * @api {post} /api/ml/trained_models/ingest_pipeline_simulate Ingest pipeline simulate - * @apiName IngestPipelineSimulate - * @apiDescription Simulates an ingest pipeline call using supplied documents - */ - router.post( - { - path: '/api/ml/trained_models/ingest_pipeline_simulate', - validate: { - body: pipelineSchema, - }, - options: { - tags: ['access:ml:canStartStopTrainedModels'], - }, - }, - routeGuard.fullLicenseAPIGuard(async ({ client, request, response }) => { - try { - const { pipeline, docs, verbose } = request.body; - - const body = await client.asCurrentUser.ingest.simulate({ - verbose, - body: { - pipeline, - docs: docs as estypes.IngestSimulateDocument[], - }, - }); - return response.ok({ - body, - }); - } catch (e) { - return response.customError(wrapError(e)); - } - }) - ); } diff --git a/x-pack/plugins/ml/tsconfig.json b/x-pack/plugins/ml/tsconfig.json index a937586369ef486..bd89d383adcef32 100644 --- a/x-pack/plugins/ml/tsconfig.json +++ b/x-pack/plugins/ml/tsconfig.json @@ -24,6 +24,7 @@ { "path": "../cloud/tsconfig.json" }, { "path": "../features/tsconfig.json" }, { "path": "../data_visualizer/tsconfig.json"}, + { "path": "../aiops/tsconfig.json"}, { "path": "../license_management/tsconfig.json" }, { "path": "../licensing/tsconfig.json" }, { "path": "../maps/tsconfig.json" }, diff --git a/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/get_elasticsearch_settings_cluster.ts b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/get_elasticsearch_settings_cluster.ts new file mode 100644 index 000000000000000..e68a2920155a58d --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/get_elasticsearch_settings_cluster.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; + +export const getElasticsearchSettingsClusterResponsePayloadRT = rt.type({ + // TODO: add payload entries +}); diff --git a/x-pack/plugins/lens/common/expressions/expression_types/index.ts b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/get_elasticsearch_settings_nodes.ts similarity index 64% rename from x-pack/plugins/lens/common/expressions/expression_types/index.ts rename to x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/get_elasticsearch_settings_nodes.ts index 78821e429fa8f12..2621683b85d9765 100644 --- a/x-pack/plugins/lens/common/expressions/expression_types/index.ts +++ b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/get_elasticsearch_settings_nodes.ts @@ -5,5 +5,8 @@ * 2.0. */ -export { lensMultitable } from './lens_multitable'; -export type { LensMultitableExpressionTypeDefinition } from './lens_multitable'; +import * as rt from 'io-ts'; + +export const getElasticsearchSettingsNodesResponsePayloadRT = rt.type({ + // TODO: add payload entries +}); diff --git a/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/index.ts b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/index.ts new file mode 100644 index 000000000000000..3268982b69b9a15 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './get_elasticsearch_settings_cluster'; +export * from './get_elasticsearch_settings_nodes'; +export * from './post_elasticsearch_settings_internal_monitoring'; +export * from './put_elasticsearch_settings_collection_enabled'; +export * from './put_elasticsearch_settings_collection_interval'; diff --git a/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/post_elasticsearch_settings_internal_monitoring.ts b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/post_elasticsearch_settings_internal_monitoring.ts new file mode 100644 index 000000000000000..54b65d4c1c52761 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/post_elasticsearch_settings_internal_monitoring.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; +import { ccsRT } from '../shared'; + +export const postElasticsearchSettingsInternalMonitoringRequestPayloadRT = rt.partial({ + ccs: ccsRT, +}); + +export type PostElasticsearchSettingsInternalMonitoringRequestPayload = rt.TypeOf< + typeof postElasticsearchSettingsInternalMonitoringRequestPayloadRT +>; + +export const postElasticsearchSettingsInternalMonitoringResponsePayloadRT = rt.type({ + body: rt.type({ + legacy_indices: rt.number, + mb_indices: rt.number, + }), +}); diff --git a/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/put_elasticsearch_settings_collection_enabled.ts b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/put_elasticsearch_settings_collection_enabled.ts new file mode 100644 index 000000000000000..f65fdaddc45488d --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/put_elasticsearch_settings_collection_enabled.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; + +export const putElasticsearchSettingsCollectionEnabledResponsePayloadRT = rt.type({ + // TODO: add payload entries +}); diff --git a/x-pack/plugins/maps/server/mvt/util.ts b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/put_elasticsearch_settings_collection_interval.ts similarity index 62% rename from x-pack/plugins/maps/server/mvt/util.ts rename to x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/put_elasticsearch_settings_collection_interval.ts index d99bdfa0b7c5237..da4905c044fe02b 100644 --- a/x-pack/plugins/maps/server/mvt/util.ts +++ b/x-pack/plugins/monitoring/common/http_api/elasticsearch_settings/put_elasticsearch_settings_collection_interval.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { errors } from '@elastic/elasticsearch'; +import * as rt from 'io-ts'; -export function isAbortError(error: Error) { - return error instanceof errors.RequestAbortedError; -} +export const putElasticsearchSettingsCollectionIntervalResponsePayloadRT = rt.type({ + // TODO: add payload entries +}); diff --git a/x-pack/plugins/monitoring/public/alerts/badge.tsx b/x-pack/plugins/monitoring/public/alerts/badge.tsx index 6ccca9278edc0a3..260a739ba0fb8bc 100644 --- a/x-pack/plugins/monitoring/public/alerts/badge.tsx +++ b/x-pack/plugins/monitoring/public/alerts/badge.tsx @@ -10,8 +10,6 @@ import { i18n } from '@kbn/i18n'; import { EuiContextMenu, EuiPopover, EuiBadge, EuiSwitch } from '@elastic/eui'; import { AlertState, CommonAlertStatus } from '../../common/types/alerts'; import { AlertSeverity } from '../../common/enums'; -// @ts-ignore -import { formatDateTimeLocal } from '../../common/formatting'; import { isInSetupMode } from '../lib/setup_mode'; import { SetupModeContext } from '../components/setup_mode/setup_mode_context'; import { getAlertPanelsByCategory } from './lib/get_alert_panels_by_category'; diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/advanced.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/advanced.tsx index 10c2a1f9761dee4..29b2e4f6c1e4438 100644 --- a/x-pack/plugins/monitoring/public/application/pages/logstash/advanced.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/advanced.tsx @@ -20,8 +20,6 @@ import { useRouteMatch } from 'react-router-dom'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { GlobalStateContext } from '../../contexts/global_state_context'; import { ComponentProps } from '../../route_init'; -// @ts-ignore -import { Listing } from '../../../components/logstash/listing'; import { LogstashTemplate } from './logstash_template'; // @ts-ignore import { DetailStatus } from '../../../components/logstash/detail_status'; diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/node.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/node.tsx index 32e4eb1dd7a7b5b..772bf5718c4ec32 100644 --- a/x-pack/plugins/monitoring/public/application/pages/logstash/node.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/node.tsx @@ -21,8 +21,6 @@ import { useKibana } from '@kbn/kibana-react-plugin/public'; import { GlobalStateContext } from '../../contexts/global_state_context'; import { ComponentProps } from '../../route_init'; // @ts-ignore -import { List } from '../../../components/logstash/pipeline_viewer/models/list'; -// @ts-ignore import { LogstashTemplate } from './logstash_template'; // @ts-ignore import { DetailStatus } from '../../../components/logstash/detail_status'; diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/node_pipelines.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/node_pipelines.tsx index a04d55a24973420..90c8421a31eb006 100644 --- a/x-pack/plugins/monitoring/public/application/pages/logstash/node_pipelines.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/node_pipelines.tsx @@ -13,13 +13,9 @@ import { useKibana } from '@kbn/kibana-react-plugin/public'; import { isPipelineMonitoringSupportedInVersion } from '../../../lib/logstash/pipelines'; import { GlobalStateContext } from '../../contexts/global_state_context'; import { ComponentProps } from '../../route_init'; -// @ts-expect-error -import { Listing } from '../../../components/logstash/listing'; import { LogstashTemplate } from './logstash_template'; // @ts-expect-error import { DetailStatus } from '../../../components/logstash/detail_status'; -// @ts-expect-error -import { MonitoringTimeseriesContainer } from '../../../components/chart'; import { useTable } from '../../hooks/use_table'; // @ts-expect-error import { PipelineListing } from '../../../components/logstash/pipeline_listing/pipeline_listing'; diff --git a/x-pack/plugins/monitoring/public/components/no_data/__snapshots__/checker_errors.test.js.snap b/x-pack/plugins/monitoring/public/components/no_data/__snapshots__/checker_errors.test.js.snap index e3fa9da6639b3f6..dff498b7f0ccd0a 100644 --- a/x-pack/plugins/monitoring/public/components/no_data/__snapshots__/checker_errors.test.js.snap +++ b/x-pack/plugins/monitoring/public/components/no_data/__snapshots__/checker_errors.test.js.snap @@ -5,7 +5,7 @@ exports[`CheckerErrors should render nothing if errors is empty 1`] = `null`; exports[`CheckerErrors should render typical boom errors from api response 1`] = ` Array [
    ,



    , + -
    - -

    - - -1 - , - "property": - xpack.monitoring.collection.enabled - , - } - } - > - We checked the cluster settings and found that - - - xpack.monitoring.collection.enabled - - - is set to - - - -1 - - - . - -

    -

    - - Would you like to turn it on? - + Monitoring provides insight to your hardware performance and load.

    -
    - -
    - - , +
    , +
    +

    + We checked the cluster settings and found that + + xpack.monitoring.collection.enabled + + is set to + + -1 + + . +

    +

    + Would you like to turn it on? +

    +
    , +
    , +
    - -
    - - - - - -
    -
    + Turn on monitoring + + +
    - - +
    , +] `; diff --git a/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_enabled/collection_enabled.test.js b/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_enabled/collection_enabled.test.js index 95dc62abdf9d253..d7957dcc457ecf9 100644 --- a/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_enabled/collection_enabled.test.js +++ b/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_enabled/collection_enabled.test.js @@ -26,7 +26,7 @@ describe('ExplainCollectionEnabled', () => { test('should explain about xpack.monitoring.collection.enabled setting', () => { const rendered = mountWithIntl(component); - expect(rendered).toMatchSnapshot(); + expect(rendered.render()).toMatchSnapshot(); }); test('should have a button that triggers ajax action', () => { diff --git a/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_interval/__snapshots__/collection_interval.test.js.snap b/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_interval/__snapshots__/collection_interval.test.js.snap index dc0253e80fecbfc..84486fafca89a7c 100644 --- a/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_interval/__snapshots__/collection_interval.test.js.snap +++ b/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_interval/__snapshots__/collection_interval.test.js.snap @@ -138,8 +138,91 @@ exports[`ExplainCollectionInterval collection interval setting updates should sh size="half" >
    + css="unknown styles" + > + + + + , + , + ], + }, + } + } + isStringTag={true} + serialized={ + Object { + "map": "/*# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3NyYy9jb21wb25lbnRzL2hvcml6b250YWxfcnVsZS9ob3Jpem9udGFsX3J1bGUuc3R5bGVzLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQTRDUSIsImZpbGUiOiIuLi8uLi8uLi9zcmMvY29tcG9uZW50cy9ob3Jpem9udGFsX3J1bGUvaG9yaXpvbnRhbF9ydWxlLnN0eWxlcy50cyIsInNvdXJjZXNDb250ZW50IjpbIi8qXG4gKiBDb3B5cmlnaHQgRWxhc3RpY3NlYXJjaCBCLlYuIGFuZC9vciBsaWNlbnNlZCB0byBFbGFzdGljc2VhcmNoIEIuVi4gdW5kZXIgb25lXG4gKiBvciBtb3JlIGNvbnRyaWJ1dG9yIGxpY2Vuc2UgYWdyZWVtZW50cy4gTGljZW5zZWQgdW5kZXIgdGhlIEVsYXN0aWMgTGljZW5zZVxuICogMi4wIGFuZCB0aGUgU2VydmVyIFNpZGUgUHVibGljIExpY2Vuc2UsIHYgMTsgeW91IG1heSBub3QgdXNlIHRoaXMgZmlsZSBleGNlcHRcbiAqIGluIGNvbXBsaWFuY2Ugd2l0aCwgYXQgeW91ciBlbGVjdGlvbiwgdGhlIEVsYXN0aWMgTGljZW5zZSAyLjAgb3IgdGhlIFNlcnZlclxuICogU2lkZSBQdWJsaWMgTGljZW5zZSwgdiAxLlxuICovXG5cbmltcG9ydCB7IGNzcyB9IGZyb20gJ0BlbW90aW9uL3JlYWN0JztcbmltcG9ydCB7IFVzZUV1aVRoZW1lIH0gZnJvbSAnLi4vLi4vc2VydmljZXMnO1xuXG5leHBvcnQgY29uc3QgZXVpSG9yaXpvbnRhbFJ1bGVTdHlsZXMgPSAoeyBldWlUaGVtZSB9OiBVc2VFdWlUaGVtZSkgPT4gKHtcbiAgZXVpSG9yaXpvbnRhbFJ1bGU6IGNzc2BcbiAgICBib3JkZXI6IG5vbmU7XG4gICAgaGVpZ2h0OiAke2V1aVRoZW1lLmJvcmRlci53aWR0aC50aGlufTtcbiAgICBiYWNrZ3JvdW5kLWNvbG9yOiAke2V1aVRoZW1lLmJvcmRlci5jb2xvcn07XG4gICAgZmxleC1zaHJpbms6IDA7IC8vIEVuc3VyZSB3aGVuIHVzZWQgaW4gZmxleCBncm91cCwgaXQgcmV0YWlucyBpdHMgc2l6ZVxuICAgIGZsZXgtZ3JvdzogMDsgLy8gRW5zdXJlIHdoZW4gdXNlZCBpbiBmbGV4IGdyb3VwLCBpdCByZXRhaW5zIGl0cyBzaXplXG4gIGAsXG5cbiAgLy8gU2l6ZXNcbiAgZnVsbDogY3NzYFxuICAgIHdpZHRoOiAxMDAlO1xuICBgLFxuICBoYWxmOiBjc3NgXG4gICAgd2lkdGg6IDUwJTtcbiAgICBtYXJnaW4taW5saW5lOiBhdXRvO1xuICBgLFxuICBxdWFydGVyOiBjc3NgXG4gICAgd2lkdGg6IDI1JTtcbiAgICBtYXJnaW4taW5saW5lOiBhdXRvO1xuICBgLFxuXG4gIC8vIE1hcmdpbnNcbiAgbm9uZTogJycsXG4gIHhzOiBjc3NgXG4gICAgbWFyZ2luLWJsb2NrOiAke2V1aVRoZW1lLnNpemUuc307XG4gIGAsXG4gIHM6IGNzc2BcbiAgICBtYXJnaW4tYmxvY2s6ICR7ZXVpVGhlbWUuc2l6ZS5tfTtcbiAgYCxcbiAgbTogY3NzYFxuICAgIG1hcmdpbi1ibG9jazogJHtldWlUaGVtZS5zaXplLmJhc2V9O1xuICBgLFxuICBsOiBjc3NgXG4gICAgbWFyZ2luLWJsb2NrOiAke2V1aVRoZW1lLnNpemUubH07XG4gIGAsXG4gIHhsOiBjc3NgXG4gICAgbWFyZ2luLWJsb2NrOiAke2V1aVRoZW1lLnNpemUueGx9O1xuICBgLFxuICB4eGw6IGNzc2BcbiAgICBtYXJnaW4tYmxvY2s6ICR7ZXVpVGhlbWUuc2l6ZS54eGx9O1xuICBgLFxufSk7XG4iXX0= */", + "name": "ilegow-euiHorizontalRule-half-l", + "next": undefined, + "styles": "border:none;height:1px;background-color:#D3DAE6;flex-shrink:0;flex-grow:0;;label:euiHorizontalRule;;;width:50%;margin-inline:auto;label:half;;;margin-block:24px;;label:l;;;", + "toString": [Function], + } + } + /> +
    +
    + css="unknown styles" + > + + + + , + , + ], + }, + } + } + isStringTag={true} + serialized={ + Object { + "map": "/*# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3NyYy9jb21wb25lbnRzL3NwYWNlci9zcGFjZXIuc3R5bGVzLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQTBCUSIsImZpbGUiOiIuLi8uLi8uLi9zcmMvY29tcG9uZW50cy9zcGFjZXIvc3BhY2VyLnN0eWxlcy50cyIsInNvdXJjZXNDb250ZW50IjpbIi8qXG4gKiBDb3B5cmlnaHQgRWxhc3RpY3NlYXJjaCBCLlYuIGFuZC9vciBsaWNlbnNlZCB0byBFbGFzdGljc2VhcmNoIEIuVi4gdW5kZXIgb25lXG4gKiBvciBtb3JlIGNvbnRyaWJ1dG9yIGxpY2Vuc2UgYWdyZWVtZW50cy4gTGljZW5zZWQgdW5kZXIgdGhlIEVsYXN0aWMgTGljZW5zZVxuICogMi4wIGFuZCB0aGUgU2VydmVyIFNpZGUgUHVibGljIExpY2Vuc2UsIHYgMTsgeW91IG1heSBub3QgdXNlIHRoaXMgZmlsZSBleGNlcHRcbiAqIGluIGNvbXBsaWFuY2Ugd2l0aCwgYXQgeW91ciBlbGVjdGlvbiwgdGhlIEVsYXN0aWMgTGljZW5zZSAyLjAgb3IgdGhlIFNlcnZlclxuICogU2lkZSBQdWJsaWMgTGljZW5zZSwgdiAxLlxuICovXG5cbmltcG9ydCB7IGNzcyB9IGZyb20gJ0BlbW90aW9uL3JlYWN0JztcbmltcG9ydCB7IFVzZUV1aVRoZW1lIH0gZnJvbSAnLi4vLi4vc2VydmljZXMnO1xuXG5leHBvcnQgY29uc3QgZXVpU3BhY2VyU3R5bGVzID0gKHsgZXVpVGhlbWUgfTogVXNlRXVpVGhlbWUpID0+ICh7XG4gIC8vIGJhc2VcbiAgZXVpU3BhY2VyOiBjc3NgXG4gICAgZmxleC1zaHJpbms6IDA7IC8vIGRvbid0IGV2ZXIgbGV0IHRoaXMgc2hyaW5rIGluIGhlaWdodCBpZiBkaXJlY3QgZGVzY2VuZGVudCBvZiBmbGV4O1xuICBgLFxuICAvLyB2YXJpYW50c1xuICB4czogY3NzYFxuICAgIGhlaWdodDogJHtldWlUaGVtZS5zaXplLnhzfTtcbiAgYCxcbiAgczogY3NzYFxuICAgIGhlaWdodDogJHtldWlUaGVtZS5zaXplLnN9O1xuICBgLFxuICBtOiBjc3NgXG4gICAgaGVpZ2h0OiAke2V1aVRoZW1lLnNpemUuYmFzZX07XG4gIGAsXG4gIGw6IGNzc2BcbiAgICBoZWlnaHQ6ICR7ZXVpVGhlbWUuc2l6ZS5sfTtcbiAgYCxcbiAgeGw6IGNzc2BcbiAgICBoZWlnaHQ6ICR7ZXVpVGhlbWUuc2l6ZS54bH07XG4gIGAsXG4gIHh4bDogY3NzYFxuICAgIGhlaWdodDogJHtldWlUaGVtZS5zaXplLnh4bH07XG4gIGAsXG59KTtcbiJdfQ== */", + "name": "jz428s-euiSpacer-l", + "next": undefined, + "styles": "flex-shrink:0;label:euiSpacer;;;height:24px;;label:l;;;", + "toString": [Function], + } + } + /> +
    +

    + css="unknown styles" + > + + + + , + , + ], + }, + } + } + isStringTag={true} + serialized={ + Object { + "map": "/*# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3NyYy9jb21wb25lbnRzL2hvcml6b250YWxfcnVsZS9ob3Jpem9udGFsX3J1bGUuc3R5bGVzLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQTRDUSIsImZpbGUiOiIuLi8uLi8uLi9zcmMvY29tcG9uZW50cy9ob3Jpem9udGFsX3J1bGUvaG9yaXpvbnRhbF9ydWxlLnN0eWxlcy50cyIsInNvdXJjZXNDb250ZW50IjpbIi8qXG4gKiBDb3B5cmlnaHQgRWxhc3RpY3NlYXJjaCBCLlYuIGFuZC9vciBsaWNlbnNlZCB0byBFbGFzdGljc2VhcmNoIEIuVi4gdW5kZXIgb25lXG4gKiBvciBtb3JlIGNvbnRyaWJ1dG9yIGxpY2Vuc2UgYWdyZWVtZW50cy4gTGljZW5zZWQgdW5kZXIgdGhlIEVsYXN0aWMgTGljZW5zZVxuICogMi4wIGFuZCB0aGUgU2VydmVyIFNpZGUgUHVibGljIExpY2Vuc2UsIHYgMTsgeW91IG1heSBub3QgdXNlIHRoaXMgZmlsZSBleGNlcHRcbiAqIGluIGNvbXBsaWFuY2Ugd2l0aCwgYXQgeW91ciBlbGVjdGlvbiwgdGhlIEVsYXN0aWMgTGljZW5zZSAyLjAgb3IgdGhlIFNlcnZlclxuICogU2lkZSBQdWJsaWMgTGljZW5zZSwgdiAxLlxuICovXG5cbmltcG9ydCB7IGNzcyB9IGZyb20gJ0BlbW90aW9uL3JlYWN0JztcbmltcG9ydCB7IFVzZUV1aVRoZW1lIH0gZnJvbSAnLi4vLi4vc2VydmljZXMnO1xuXG5leHBvcnQgY29uc3QgZXVpSG9yaXpvbnRhbFJ1bGVTdHlsZXMgPSAoeyBldWlUaGVtZSB9OiBVc2VFdWlUaGVtZSkgPT4gKHtcbiAgZXVpSG9yaXpvbnRhbFJ1bGU6IGNzc2BcbiAgICBib3JkZXI6IG5vbmU7XG4gICAgaGVpZ2h0OiAke2V1aVRoZW1lLmJvcmRlci53aWR0aC50aGlufTtcbiAgICBiYWNrZ3JvdW5kLWNvbG9yOiAke2V1aVRoZW1lLmJvcmRlci5jb2xvcn07XG4gICAgZmxleC1zaHJpbms6IDA7IC8vIEVuc3VyZSB3aGVuIHVzZWQgaW4gZmxleCBncm91cCwgaXQgcmV0YWlucyBpdHMgc2l6ZVxuICAgIGZsZXgtZ3JvdzogMDsgLy8gRW5zdXJlIHdoZW4gdXNlZCBpbiBmbGV4IGdyb3VwLCBpdCByZXRhaW5zIGl0cyBzaXplXG4gIGAsXG5cbiAgLy8gU2l6ZXNcbiAgZnVsbDogY3NzYFxuICAgIHdpZHRoOiAxMDAlO1xuICBgLFxuICBoYWxmOiBjc3NgXG4gICAgd2lkdGg6IDUwJTtcbiAgICBtYXJnaW4taW5saW5lOiBhdXRvO1xuICBgLFxuICBxdWFydGVyOiBjc3NgXG4gICAgd2lkdGg6IDI1JTtcbiAgICBtYXJnaW4taW5saW5lOiBhdXRvO1xuICBgLFxuXG4gIC8vIE1hcmdpbnNcbiAgbm9uZTogJycsXG4gIHhzOiBjc3NgXG4gICAgbWFyZ2luLWJsb2NrOiAke2V1aVRoZW1lLnNpemUuc307XG4gIGAsXG4gIHM6IGNzc2BcbiAgICBtYXJnaW4tYmxvY2s6ICR7ZXVpVGhlbWUuc2l6ZS5tfTtcbiAgYCxcbiAgbTogY3NzYFxuICAgIG1hcmdpbi1ibG9jazogJHtldWlUaGVtZS5zaXplLmJhc2V9O1xuICBgLFxuICBsOiBjc3NgXG4gICAgbWFyZ2luLWJsb2NrOiAke2V1aVRoZW1lLnNpemUubH07XG4gIGAsXG4gIHhsOiBjc3NgXG4gICAgbWFyZ2luLWJsb2NrOiAke2V1aVRoZW1lLnNpemUueGx9O1xuICBgLFxuICB4eGw6IGNzc2BcbiAgICBtYXJnaW4tYmxvY2s6ICR7ZXVpVGhlbWUuc2l6ZS54eGx9O1xuICBgLFxufSk7XG4iXX0= */", + "name": "ilegow-euiHorizontalRule-half-l", + "next": undefined, + "styles": "border:none;height:1px;background-color:#D3DAE6;flex-shrink:0;flex-grow:0;;label:euiHorizontalRule;;;width:50%;margin-inline:auto;label:half;;;margin-block:24px;;label:l;;;", + "toString": [Function], + } + } + /> +
    +
    + css="unknown styles" + > + + + + , + , + ], + }, + } + } + isStringTag={true} + serialized={ + Object { + "map": "/*# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3NyYy9jb21wb25lbnRzL3NwYWNlci9zcGFjZXIuc3R5bGVzLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQTBCUSIsImZpbGUiOiIuLi8uLi8uLi9zcmMvY29tcG9uZW50cy9zcGFjZXIvc3BhY2VyLnN0eWxlcy50cyIsInNvdXJjZXNDb250ZW50IjpbIi8qXG4gKiBDb3B5cmlnaHQgRWxhc3RpY3NlYXJjaCBCLlYuIGFuZC9vciBsaWNlbnNlZCB0byBFbGFzdGljc2VhcmNoIEIuVi4gdW5kZXIgb25lXG4gKiBvciBtb3JlIGNvbnRyaWJ1dG9yIGxpY2Vuc2UgYWdyZWVtZW50cy4gTGljZW5zZWQgdW5kZXIgdGhlIEVsYXN0aWMgTGljZW5zZVxuICogMi4wIGFuZCB0aGUgU2VydmVyIFNpZGUgUHVibGljIExpY2Vuc2UsIHYgMTsgeW91IG1heSBub3QgdXNlIHRoaXMgZmlsZSBleGNlcHRcbiAqIGluIGNvbXBsaWFuY2Ugd2l0aCwgYXQgeW91ciBlbGVjdGlvbiwgdGhlIEVsYXN0aWMgTGljZW5zZSAyLjAgb3IgdGhlIFNlcnZlclxuICogU2lkZSBQdWJsaWMgTGljZW5zZSwgdiAxLlxuICovXG5cbmltcG9ydCB7IGNzcyB9IGZyb20gJ0BlbW90aW9uL3JlYWN0JztcbmltcG9ydCB7IFVzZUV1aVRoZW1lIH0gZnJvbSAnLi4vLi4vc2VydmljZXMnO1xuXG5leHBvcnQgY29uc3QgZXVpU3BhY2VyU3R5bGVzID0gKHsgZXVpVGhlbWUgfTogVXNlRXVpVGhlbWUpID0+ICh7XG4gIC8vIGJhc2VcbiAgZXVpU3BhY2VyOiBjc3NgXG4gICAgZmxleC1zaHJpbms6IDA7IC8vIGRvbid0IGV2ZXIgbGV0IHRoaXMgc2hyaW5rIGluIGhlaWdodCBpZiBkaXJlY3QgZGVzY2VuZGVudCBvZiBmbGV4O1xuICBgLFxuICAvLyB2YXJpYW50c1xuICB4czogY3NzYFxuICAgIGhlaWdodDogJHtldWlUaGVtZS5zaXplLnhzfTtcbiAgYCxcbiAgczogY3NzYFxuICAgIGhlaWdodDogJHtldWlUaGVtZS5zaXplLnN9O1xuICBgLFxuICBtOiBjc3NgXG4gICAgaGVpZ2h0OiAke2V1aVRoZW1lLnNpemUuYmFzZX07XG4gIGAsXG4gIGw6IGNzc2BcbiAgICBoZWlnaHQ6ICR7ZXVpVGhlbWUuc2l6ZS5sfTtcbiAgYCxcbiAgeGw6IGNzc2BcbiAgICBoZWlnaHQ6ICR7ZXVpVGhlbWUuc2l6ZS54bH07XG4gIGAsXG4gIHh4bDogY3NzYFxuICAgIGhlaWdodDogJHtldWlUaGVtZS5zaXplLnh4bH07XG4gIGAsXG59KTtcbiJdfQ== */", + "name": "jz428s-euiSpacer-l", + "next": undefined, + "styles": "flex-shrink:0;label:euiSpacer;;;height:24px;;label:l;;;", + "toString": [Function], + } + } + /> +
    +
    - - -

    - - Monitoring is currently off - -

    -
    - - - -
    -

    - - Monitoring provides insight to your hardware performance and load. - -

    -
    -
    -
    -
    -
    - + Monitoring is currently off + , + -
    -
    -

    - - -1 - , - "property": - xpack.monitoring.collection.interval - , - } - } - > - We checked the cluster settings and found that - - - xpack.monitoring.collection.interval - - - is set to - - - -1 - - - . - -

    -

    - - The collection interval setting needs to be a positive integer (10s is recommended) in order for the collection agents to be active. - -

    -

    - - Would you like us to change it and enable monitoring? - + Monitoring provides insight to your hardware performance and load.

    -
    - -
    - - , +
    , +
    +

    + We checked the cluster settings and found that + + xpack.monitoring.collection.interval + + is set to + + -1 + + . +

    +

    + The collection interval setting needs to be a positive integer (10s is recommended) in order for the collection agents to be active. +

    +

    + Would you like us to change it and enable monitoring? +

    +
    , +
    , +
    - -
    - - - - - -
    -
    + Turn on monitoring + + +
    - - +
    , +] `; diff --git a/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_interval/collection_interval.test.js b/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_interval/collection_interval.test.js index 95ffad81b902d7c..4b7af5e22f1d75e 100644 --- a/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_interval/collection_interval.test.js +++ b/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_interval/collection_interval.test.js @@ -32,7 +32,7 @@ describe('ExplainCollectionInterval', () => { /> ); const rendered = mountWithIntl(component); - expect(rendered).toMatchSnapshot(); + expect(rendered.render()).toMatchSnapshot(); }); test('should have a button that triggers ajax action', () => { diff --git a/x-pack/plugins/monitoring/public/components/no_data/explanations/exporters/__snapshots__/exporters.test.js.snap b/x-pack/plugins/monitoring/public/components/no_data/explanations/exporters/__snapshots__/exporters.test.js.snap index 41501a7eedb62dd..494985be0a6bf3d 100644 --- a/x-pack/plugins/monitoring/public/components/no_data/explanations/exporters/__snapshots__/exporters.test.js.snap +++ b/x-pack/plugins/monitoring/public/components/no_data/explanations/exporters/__snapshots__/exporters.test.js.snap @@ -19,7 +19,7 @@ Array [
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,

    { @@ -101,9 +107,11 @@ export function internalMonitoringCheckRoute(server: LegacyServer, npRoute: Rout typeCount.mb_indices += counts.mbIndicesCount; }); - return response.ok({ - body: typeCount, - }); + return response.ok( + postElasticsearchSettingsInternalMonitoringResponsePayloadRT.encode({ + body: typeCount, + }) + ); } catch (err) { throw handleError(err); } diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/nodes.js b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/nodes.ts similarity index 67% rename from x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/nodes.js rename to x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/nodes.ts index fe675302a982fd0..90c37c6f910c946 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/nodes.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/nodes.ts @@ -5,23 +5,23 @@ * 2.0. */ +import { getElasticsearchSettingsNodesResponsePayloadRT } from '../../../../../../common/http_api/elasticsearch_settings'; import { checkNodesSettings } from '../../../../../lib/elasticsearch_settings'; import { handleSettingsError } from '../../../../../lib/errors'; +import { MonitoringCore } from '../../../../../types'; /* * Cluster Settings Check Route */ -export function nodesSettingsCheckRoute(server) { +export function nodesSettingsCheckRoute(server: MonitoringCore) { server.route({ - method: 'GET', + method: 'get', path: '/api/monitoring/v1/elasticsearch_settings/check/nodes', - config: { - validate: {}, - }, + validate: {}, async handler(req) { try { const response = await checkNodesSettings(req); // needs to be try/catch to handle privilege error - return response; + return getElasticsearchSettingsNodesResponsePayloadRT.encode(response); } catch (err) { throw handleSettingsError(err); } diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.js b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.ts similarity index 100% rename from x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.js rename to x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.ts index 8eb50a57fb858ce..61bb1ba804a5ac4 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.ts @@ -5,8 +5,8 @@ * 2.0. */ -export { internalMonitoringCheckRoute } from './check/internal_monitoring'; export { clusterSettingsCheckRoute } from './check/cluster'; +export { internalMonitoringCheckRoute } from './check/internal_monitoring'; export { nodesSettingsCheckRoute } from './check/nodes'; export { setCollectionEnabledRoute } from './set/collection_enabled'; export { setCollectionIntervalRoute } from './set/collection_interval'; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_enabled.js b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_enabled.ts similarity index 64% rename from x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_enabled.js rename to x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_enabled.ts index c8bf24156f129e0..941818699ede20d 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_enabled.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_enabled.ts @@ -5,23 +5,23 @@ * 2.0. */ +import { putElasticsearchSettingsCollectionEnabledResponsePayloadRT } from '../../../../../../common/http_api/elasticsearch_settings'; import { setCollectionEnabled } from '../../../../../lib/elasticsearch_settings'; import { handleSettingsError } from '../../../../../lib/errors'; +import { MonitoringCore } from '../../../../../types'; /* * Cluster Settings Check Route */ -export function setCollectionEnabledRoute(server) { +export function setCollectionEnabledRoute(server: MonitoringCore) { server.route({ - method: 'PUT', + method: 'put', path: '/api/monitoring/v1/elasticsearch_settings/set/collection_enabled', - config: { - validate: {}, - }, + validate: {}, async handler(req) { try { const response = await setCollectionEnabled(req); - return response; + return putElasticsearchSettingsCollectionEnabledResponsePayloadRT.encode(response); } catch (err) { throw handleSettingsError(err); } diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_interval.js b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_interval.ts similarity index 64% rename from x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_interval.js rename to x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_interval.ts index 60216650062c05a..eb4798efc36cc91 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_interval.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/set/collection_interval.ts @@ -5,23 +5,23 @@ * 2.0. */ +import { putElasticsearchSettingsCollectionIntervalResponsePayloadRT } from '../../../../../../common/http_api/elasticsearch_settings'; import { setCollectionInterval } from '../../../../../lib/elasticsearch_settings'; import { handleSettingsError } from '../../../../../lib/errors'; +import { MonitoringCore } from '../../../../../types'; /* * Cluster Settings Check Route */ -export function setCollectionIntervalRoute(server) { +export function setCollectionIntervalRoute(server: MonitoringCore) { server.route({ - method: 'PUT', + method: 'put', path: '/api/monitoring/v1/elasticsearch_settings/set/collection_interval', - config: { - validate: {}, - }, + validate: {}, async handler(req) { try { const response = await setCollectionInterval(req); - return response; + return putElasticsearchSettingsCollectionIntervalResponsePayloadRT.encode(response); } catch (err) { throw handleSettingsError(err); } diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipeline.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipeline.js index 128df1b147cc67c..fc06e36fe9132da 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipeline.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipeline.js @@ -5,8 +5,9 @@ * 2.0. */ +import { notFound } from '@hapi/boom'; import { schema } from '@kbn/config-schema'; -import { handleError } from '../../../../lib/errors'; +import { handleError, PipelineNotFoundError } from '../../../../lib/errors'; import { getPipelineVersions } from '../../../../lib/logstash/get_pipeline_versions'; import { getPipeline } from '../../../../lib/logstash/get_pipeline'; import { getPipelineVertex } from '../../../../lib/logstash/get_pipeline_vertex'; @@ -82,6 +83,10 @@ export function logstashPipelineRoute(server) { vertex, }; } catch (err) { + if (err instanceof PipelineNotFoundError) { + req.getLogger().error(err.message); + throw notFound(err.message); + } return handleError(err, req); } }, diff --git a/x-pack/plugins/observability/common/ui_settings_keys.ts b/x-pack/plugins/observability/common/ui_settings_keys.ts index 4c1b1dc729feab9..287fe541cc7b6c6 100644 --- a/x-pack/plugins/observability/common/ui_settings_keys.ts +++ b/x-pack/plugins/observability/common/ui_settings_keys.ts @@ -5,6 +5,7 @@ * 2.0. */ +export const enableNewSyntheticsView = 'observability:enableNewSyntheticsView'; export const enableInspectEsQueries = 'observability:enableInspectEsQueries'; export const maxSuggestions = 'observability:maxSuggestions'; export const enableComparisonByDefault = 'observability:enableComparisonByDefault'; diff --git a/x-pack/plugins/observability/kibana.json b/x-pack/plugins/observability/kibana.json index 2ba9572dfd243a6..6cadc6403ad1746 100644 --- a/x-pack/plugins/observability/kibana.json +++ b/x-pack/plugins/observability/kibana.json @@ -41,7 +41,8 @@ "embeddable", "kibanaReact", "kibanaUtils", - "lens" + "lens", + "usageCollection" ], "extraPublicDirs": [ "common" diff --git a/x-pack/plugins/observability/public/application/application.test.tsx b/x-pack/plugins/observability/public/application/application.test.tsx index a03f007c2d751d9..9cf0998e5d7c77b 100644 --- a/x-pack/plugins/observability/public/application/application.test.tsx +++ b/x-pack/plugins/observability/public/application/application.test.tsx @@ -83,6 +83,12 @@ describe('renderApp', () => { observabilityRuleTypeRegistry: createObservabilityRuleTypeRegistryMock(), ObservabilityPageTemplate: KibanaPageTemplate, kibanaFeatures: [], + usageCollection: { + components: { + ApplicationUsageTrackingProvider: (props) => null, + }, + reportUiCounter: jest.fn(), + }, }); unmount(); }).not.toThrowError(); diff --git a/x-pack/plugins/observability/public/application/index.tsx b/x-pack/plugins/observability/public/application/index.tsx index bab6c03b5cf549e..c48a663fefe5b22 100644 --- a/x-pack/plugins/observability/public/application/index.tsx +++ b/x-pack/plugins/observability/public/application/index.tsx @@ -18,6 +18,7 @@ import { RedirectAppLinks, } from '@kbn/kibana-react-plugin/public'; import { Storage } from '@kbn/kibana-utils-plugin/public'; +import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; import { ConfigSchema } from '..'; import type { LazyObservabilityPageTemplateProps } from '../components/shared/page_template/lazy_page_template'; import { DatePickerContextProvider } from '../context/date_picker_context'; @@ -54,6 +55,7 @@ export const renderApp = ({ observabilityRuleTypeRegistry, ObservabilityPageTemplate, kibanaFeatures, + usageCollection, }: { config: ConfigSchema; core: CoreStart; @@ -62,6 +64,7 @@ export const renderApp = ({ appMountParameters: AppMountParameters; ObservabilityPageTemplate: React.ComponentType; kibanaFeatures: KibanaFeature[]; + usageCollection: UsageCollectionSetup; }) => { const { element, history, theme$ } = appMountParameters; const i18nCore = core.i18n; @@ -77,34 +80,40 @@ export const renderApp = ({ // ensure all divs are .kbnAppWrappers element.classList.add(APP_WRAPPER_CLASS); + const ApplicationUsageTrackingProvider = + usageCollection?.components.ApplicationUsageTrackingProvider ?? React.Fragment; ReactDOM.render( - - - + + - - - - - - - - - - - - - - - - , + + + + + + + + + + + + + + + + + + , element ); return () => { diff --git a/x-pack/plugins/observability/public/config/index.ts b/x-pack/plugins/observability/public/config/index.ts index fc6300acc47169c..34d783180750b26 100644 --- a/x-pack/plugins/observability/public/config/index.ts +++ b/x-pack/plugins/observability/public/config/index.ts @@ -7,3 +7,9 @@ export { paths } from './paths'; export { translations } from './translations'; + +export enum AlertingPages { + alerts = 'alerts', + cases = 'cases', + rules = 'rules', +} diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_last24h_alerts.ts b/x-pack/plugins/observability/public/hooks/use_fetch_last24h_alerts.ts new file mode 100644 index 000000000000000..cc1313be293400e --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_fetch_last24h_alerts.ts @@ -0,0 +1,159 @@ +/* + * 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. + */ +/* + * 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 { useEffect, useState, useCallback, useRef } from 'react'; +import { AsApiContract } from '@kbn/actions-plugin/common'; +import { HttpSetup } from '@kbn/core/public'; +import { BASE_RAC_ALERTS_API_PATH } from '@kbn/rule-registry-plugin/common/constants'; +import { RULE_LOAD_ERROR } from '../pages/rule_details/translations'; + +interface UseFetchLast24hAlertsProps { + http: HttpSetup; + features: string; + ruleId: string; +} +interface FetchLast24hAlerts { + isLoadingLast24hAlerts: boolean; + last24hAlerts: number; + errorLast24hAlerts: string | undefined; +} + +export function useFetchLast24hAlerts({ http, features, ruleId }: UseFetchLast24hAlertsProps) { + const [last24hAlerts, setLast24hAlerts] = useState({ + isLoadingLast24hAlerts: true, + last24hAlerts: 0, + errorLast24hAlerts: undefined, + }); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); + const fetchLast24hAlerts = useCallback(async () => { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + try { + if (!features) return; + const { index } = await fetchIndexNameAPI({ + http, + features, + }); + const { error, alertsCount } = await fetchLast24hAlertsAPI({ + http, + index, + ruleId, + signal: abortCtrlRef.current.signal, + }); + if (error) throw error; + if (!isCancelledRef.current) { + setLast24hAlerts((oldState: FetchLast24hAlerts) => ({ + ...oldState, + last24hAlerts: alertsCount, + isLoading: false, + })); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { + setLast24hAlerts((oldState: FetchLast24hAlerts) => ({ + ...oldState, + isLoading: false, + errorLast24hAlerts: RULE_LOAD_ERROR( + error instanceof Error ? error.message : typeof error === 'string' ? error : '' + ), + })); + } + } + } + }, [http, features, ruleId]); + useEffect(() => { + fetchLast24hAlerts(); + }, [fetchLast24hAlerts]); + + return last24hAlerts; +} + +interface IndexName { + index: string; +} + +export async function fetchIndexNameAPI({ + http, + features, +}: { + http: HttpSetup; + features: string; +}): Promise { + const res = await http.get<{ index_name: string[] }>(`${BASE_RAC_ALERTS_API_PATH}/index`, { + query: { features }, + }); + return { + index: res.index_name[0], + }; +} +export async function fetchLast24hAlertsAPI({ + http, + index, + ruleId, + signal, +}: { + http: HttpSetup; + index: string; + ruleId: string; + signal: AbortSignal; +}): Promise<{ + error: string | null; + alertsCount: number; +}> { + try { + const res = await http.post>(`${BASE_RAC_ALERTS_API_PATH}/find`, { + signal, + body: JSON.stringify({ + index, + query: { + bool: { + must: [ + { + term: { + 'kibana.alert.rule.uuid': ruleId, + }, + }, + { + range: { + '@timestamp': { + gte: 'now-24h', + lt: 'now', + }, + }, + }, + ], + }, + }, + aggs: { + alerts_count: { + cardinality: { + field: 'kibana.alert.uuid', + }, + }, + }, + }), + }); + return { + error: null, + alertsCount: res.aggregations.alerts_count.value, + }; + } catch (error) { + return { + error, + alertsCount: 0, + }; + } +} diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_rule.ts b/x-pack/plugins/observability/public/hooks/use_fetch_rule.ts new file mode 100644 index 000000000000000..07f13b4c80e7eb5 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_fetch_rule.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useState, useCallback } from 'react'; +import { loadRule } from '@kbn/triggers-actions-ui-plugin/public'; +import { FetchRuleProps, FetchRule } from '../pages/rule_details/types'; +import { RULE_LOAD_ERROR } from '../pages/rule_details/translations'; + +export function useFetchRule({ ruleId, http }: FetchRuleProps) { + const [ruleSummary, setRuleSummary] = useState({ + isRuleLoading: true, + rule: undefined, + errorRule: undefined, + }); + const fetchRuleSummary = useCallback(async () => { + try { + const rule = await loadRule({ + http, + ruleId, + }); + + setRuleSummary((oldState: FetchRule) => ({ + ...oldState, + isRuleLoading: false, + rule, + })); + } catch (error) { + setRuleSummary((oldState: FetchRule) => ({ + ...oldState, + isRuleLoading: false, + errorRule: RULE_LOAD_ERROR( + error instanceof Error ? error.message : typeof error === 'string' ? error : '' + ), + })); + } + }, [ruleId, http]); + useEffect(() => { + fetchRuleSummary(); + }, [fetchRuleSummary]); + + return { ...ruleSummary, reloadRule: fetchRuleSummary }; +} diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_rule_actions.ts b/x-pack/plugins/observability/public/hooks/use_fetch_rule_actions.ts new file mode 100644 index 000000000000000..eaf01ed5ba59d49 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_fetch_rule_actions.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useState, useCallback } from 'react'; +import { ActionConnector, loadAllActions } from '@kbn/triggers-actions-ui-plugin/public'; +import { FetchRuleActionsProps } from '../pages/rule_details/types'; +import { ACTIONS_LOAD_ERROR } from '../pages/rule_details/translations'; + +interface FetchActions { + isLoadingActions: boolean; + allActions: Array>>; + errorActions: string | undefined; +} + +export function useFetchRuleActions({ http }: FetchRuleActionsProps) { + const [ruleActions, setRuleActions] = useState({ + isLoadingActions: true, + allActions: [] as Array>>, + errorActions: undefined, + }); + + const fetchRuleActions = useCallback(async () => { + try { + const response = await loadAllActions({ + http, + }); + setRuleActions((oldState: FetchActions) => ({ + ...oldState, + isLoadingActions: false, + allActions: response, + })); + } catch (error) { + setRuleActions((oldState: FetchActions) => ({ + ...oldState, + isLoadingActions: false, + errorActions: ACTIONS_LOAD_ERROR( + error instanceof Error ? error.message : typeof error === 'string' ? error : '' + ), + })); + } + }, [http]); + useEffect(() => { + fetchRuleActions(); + }, [fetchRuleActions]); + + return { ...ruleActions, reloadRuleActions: fetchRuleActions }; +} diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_rule_summary.ts b/x-pack/plugins/observability/public/hooks/use_fetch_rule_summary.ts new file mode 100644 index 000000000000000..7e7c71e50332971 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_fetch_rule_summary.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect, useState, useCallback } from 'react'; +import { loadRuleSummary } from '@kbn/triggers-actions-ui-plugin/public'; +import { FetchRuleSummaryProps, FetchRuleSummary } from '../pages/rule_details/types'; +import { RULE_LOAD_ERROR } from '../pages/rule_details/translations'; + +export function useFetchRuleSummary({ ruleId, http }: FetchRuleSummaryProps) { + const [ruleSummary, setRuleSummary] = useState({ + isLoadingRuleSummary: true, + ruleSummary: undefined, + errorRuleSummary: undefined, + }); + + const fetchRuleSummary = useCallback(async () => { + setRuleSummary((oldState: FetchRuleSummary) => ({ ...oldState, isLoading: true })); + + try { + const response = await loadRuleSummary({ + http, + ruleId, + }); + setRuleSummary((oldState: FetchRuleSummary) => ({ + ...oldState, + isLoading: false, + ruleSummary: response, + })); + } catch (error) { + setRuleSummary((oldState: FetchRuleSummary) => ({ + ...oldState, + isLoading: false, + errorRuleSummary: RULE_LOAD_ERROR( + error instanceof Error ? error.message : typeof error === 'string' ? error : '' + ), + })); + } + }, [ruleId, http]); + useEffect(() => { + fetchRuleSummary(); + }, [fetchRuleSummary]); + + return { ...ruleSummary, reloadRuleSummary: fetchRuleSummary }; +} diff --git a/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts index de0127b08213e40..229a54c754e4f0b 100644 --- a/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts +++ b/x-pack/plugins/observability/public/hooks/use_fetch_rules.ts @@ -7,9 +7,9 @@ import { useEffect, useState, useCallback } from 'react'; import { isEmpty } from 'lodash'; -import { loadRules } from '@kbn/triggers-actions-ui-plugin/public'; -import { RULES_LOAD_ERROR } from '../pages/rules/translations'; -import { FetchRulesProps, RuleState } from '../pages/rules/types'; +import { loadRules, loadRuleTags } from '@kbn/triggers-actions-ui-plugin/public'; +import { RULES_LOAD_ERROR, RULE_TAGS_LOAD_ERROR } from '../pages/rules/translations'; +import { FetchRulesProps, RuleState, TagsState } from '../pages/rules/types'; import { OBSERVABILITY_RULE_TYPES } from '../pages/rules/config'; import { useKibana } from '../utils/kibana_react'; @@ -18,6 +18,7 @@ export function useFetchRules({ ruleLastResponseFilter, ruleStatusesFilter, typesFilter, + tagsFilter, setPage, page, sort, @@ -33,6 +34,23 @@ export function useFetchRules({ const [noData, setNoData] = useState(true); const [initialLoad, setInitialLoad] = useState(true); + const [tagsState, setTagsState] = useState({ + data: [], + error: null, + }); + const loadRuleTagsAggs = useCallback(async () => { + try { + const ruleTagsAggs = await loadRuleTags({ + http, + }); + + if (ruleTagsAggs?.ruleTags) { + setTagsState({ data: ruleTagsAggs.ruleTags, error: null }); + } + } catch (e) { + setTagsState((oldState: TagsState) => ({ ...oldState, error: RULE_TAGS_LOAD_ERROR })); + } + }, [http]); const fetchRules = useCallback(async () => { setRulesState((oldState) => ({ ...oldState, isLoading: true })); @@ -43,10 +61,12 @@ export function useFetchRules({ page, searchText, typesFilter: typesFilter.length > 0 ? typesFilter : OBSERVABILITY_RULE_TYPES, + tagsFilter, ruleExecutionStatusesFilter: ruleLastResponseFilter, ruleStatusesFilter, sort, }); + await loadRuleTagsAggs(); setRulesState((oldState) => ({ ...oldState, isLoading: false, @@ -60,8 +80,9 @@ export function useFetchRules({ const isFilterApplied = !( isEmpty(searchText) && isEmpty(ruleLastResponseFilter) && - isEmpty(ruleStatusesFilter) && - isEmpty(typesFilter) + isEmpty(typesFilter) && + isEmpty(tagsFilter) && + isEmpty(ruleStatusesFilter) ); setNoData(response.data.length === 0 && !isFilterApplied); @@ -75,6 +96,8 @@ export function useFetchRules({ setPage, searchText, ruleLastResponseFilter, + tagsFilter, + loadRuleTagsAggs, ruleStatusesFilter, typesFilter, sort, @@ -89,5 +112,6 @@ export function useFetchRules({ setRulesState, noData, initialLoad, + tagsState, }; } diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index 19468ef0e273697..00db5b1873980f0 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -28,6 +28,7 @@ export { enableComparisonByDefault, enableInfrastructureView, enableServiceGroups, + enableNewSyntheticsView, } from '../common/ui_settings_keys'; export { uptimeOverviewLocatorID } from '../common'; diff --git a/x-pack/plugins/observability/public/observability_public_plugins_start.mock.ts b/x-pack/plugins/observability/public/observability_public_plugins_start.mock.ts index 50836859a5415de..ab1f769c1c4b93f 100644 --- a/x-pack/plugins/observability/public/observability_public_plugins_start.mock.ts +++ b/x-pack/plugins/observability/public/observability_public_plugins_start.mock.ts @@ -39,6 +39,7 @@ const triggersActionsUiStartMock = { getRuleStatusDropdown: jest.fn(), getRuleTagBadge: jest.fn(), getRuleStatusFilter: jest.fn(), + getRuleTagFilter: jest.fn(), ruleTypeRegistry: { has: jest.fn(), register: jest.fn(), diff --git a/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/index.ts b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/index.ts new file mode 100644 index 000000000000000..b2b4e144952e06a --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { renderRuleStats } from './rule_stats'; +export type { RuleStatsState } from './types'; diff --git a/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.test.tsx b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.test.tsx new file mode 100644 index 000000000000000..6f2edf5d0b1b6be --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.test.tsx @@ -0,0 +1,199 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderRuleStats } from './rule_stats'; +import { render, screen } from '@testing-library/react'; + +const RULES_PAGE_LINK = '/app/observability/alerts/rules'; +const STAT_CLASS = 'euiStat'; +const STAT_TITLE_PRIMARY_CLASS = 'euiStat__title--primary'; +const STAT_BUTTON_CLASS = 'euiButtonEmpty'; + +describe('Rule stats', () => { + test('renders all rule stats', async () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 0, + error: 0, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + expect(stats.length).toEqual(6); + }); + test('disabled stat is not clickable, when there are no disabled rules', async () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 0, + error: 0, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + const { findByText, container } = render(stats[4]); + const disabledElement = await findByText('Disabled'); + expect(disabledElement).toBeInTheDocument(); + expect(container.getElementsByClassName(STAT_CLASS).length).toBe(1); + expect(container.getElementsByClassName(STAT_TITLE_PRIMARY_CLASS).length).toBe(0); + expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(0); + }); + + test('disabled stat is clickable, when there are disabled rules', async () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 1, + muted: 0, + error: 0, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + const { container } = render(stats[4]); + expect(screen.getByText('Disabled').closest('a')).toHaveAttribute( + 'href', + `${RULES_PAGE_LINK}?_a=(lastResponse:!(),status:!(disabled))` + ); + + expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(1); + }); + + test('disabled stat count is link-colored, when there are disabled rules', async () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 1, + muted: 0, + error: 0, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + const { container } = render(stats[4]); + expect(container.getElementsByClassName(STAT_TITLE_PRIMARY_CLASS).length).toBe(1); + }); + + test('snoozed stat is not clickable, when there are no snoozed rules', async () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 0, + error: 0, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + const { findByText, container } = render(stats[3]); + const snoozedElement = await findByText('Snoozed'); + expect(snoozedElement).toBeInTheDocument(); + expect(container.getElementsByClassName(STAT_CLASS).length).toBe(1); + expect(container.getElementsByClassName(STAT_TITLE_PRIMARY_CLASS).length).toBe(0); + expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(0); + }); + + test('snoozed stat is clickable, when there are snoozed rules', () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 1, + error: 0, + snoozed: 1, + }, + RULES_PAGE_LINK, + false + ); + const { container } = render(stats[3]); + expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(1); + expect(screen.getByText('Snoozed').closest('a')).toHaveAttribute( + 'href', + `${RULES_PAGE_LINK}?_a=(lastResponse:!(),status:!(snoozed))` + ); + }); + + test('snoozed stat count is link-colored, when there are snoozed rules', async () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 1, + error: 0, + snoozed: 1, + }, + RULES_PAGE_LINK, + false + ); + const { container } = render(stats[3]); + expect(container.getElementsByClassName(STAT_TITLE_PRIMARY_CLASS).length).toBe(1); + }); + + test('errors stat is not clickable, when there are no error rules', async () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 0, + error: 0, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + const { findByText, container } = render(stats[2]); + const errorsElement = await findByText('Errors'); + expect(errorsElement).toBeInTheDocument(); + expect(container.getElementsByClassName(STAT_CLASS).length).toBe(1); + expect(container.getElementsByClassName(STAT_TITLE_PRIMARY_CLASS).length).toBe(0); + expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(0); + }); + + test('errors stat is clickable, when there are error rules', () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 0, + error: 2, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + const { container } = render(stats[2]); + expect(container.getElementsByClassName(STAT_BUTTON_CLASS).length).toBe(1); + expect(screen.getByText('Errors').closest('a')).toHaveAttribute( + 'href', + `${RULES_PAGE_LINK}?_a=(lastResponse:!(error),status:!())` + ); + }); + + test('errors stat count is link-colored, when there are error rules', () => { + const stats = renderRuleStats( + { + total: 11, + disabled: 0, + muted: 0, + error: 2, + snoozed: 0, + }, + RULES_PAGE_LINK, + false + ); + const { container } = render(stats[2]); + expect(container.getElementsByClassName(STAT_TITLE_PRIMARY_CLASS).length).toBe(1); + }); +}); diff --git a/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.tsx b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.tsx new file mode 100644 index 000000000000000..62c520c7b7442e4 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/rule_stats.tsx @@ -0,0 +1,156 @@ +/* + * 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 React from 'react'; +import { EuiButtonEmpty, EuiStat } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { euiStyled } from '@kbn/kibana-react-plugin/common'; + +interface RuleStatsState { + total: number; + disabled: number; + muted: number; + error: number; + snoozed: number; +} +type StatType = 'disabled' | 'snoozed' | 'error'; + +const Divider = euiStyled.div` + border-right: 1px solid ${({ theme }) => theme.eui.euiColorLightShade}; + height: 100%; +`; + +const StyledStat = euiStyled(EuiStat)` + .euiText { + line-height: 1; + } +`; + +const ConditionalWrap = ({ + condition, + wrap, + children, +}: { + condition: boolean; + wrap: (wrappedChildren: React.ReactNode) => JSX.Element; + children: JSX.Element; +}): JSX.Element => (condition ? wrap(children) : children); + +export const renderRuleStats = ( + ruleStats: RuleStatsState, + manageRulesHref: string, + ruleStatsLoading: boolean +) => { + const createRuleStatsLink = (stats: RuleStatsState, statType: StatType) => { + const count = stats[statType]; + let statsLink = `${manageRulesHref}?_a=(lastResponse:!(),status:!())`; + if (count > 0) { + switch (statType) { + case 'error': + statsLink = `${manageRulesHref}?_a=(lastResponse:!(error),status:!())`; + break; + case 'snoozed': + case 'disabled': + statsLink = `${manageRulesHref}?_a=(lastResponse:!(),status:!(${statType}))`; + break; + default: + break; + } + } + return statsLink; + }; + + const disabledStatsComponent = ( + 0} + wrap={(wrappedChildren) => ( + + {wrappedChildren} + + )} + > + 0 ? 'primary' : ''} + titleSize="xs" + isLoading={ruleStatsLoading} + data-test-subj="statDisabled" + /> + + ); + + const snoozedStatsComponent = ( + 0} + wrap={(wrappedChildren) => ( + + {wrappedChildren} + + )} + > + 0 ? 'primary' : ''} + titleSize="xs" + isLoading={ruleStatsLoading} + data-test-subj="statMuted" + /> + + ); + + const errorStatsComponent = ( + 0} + wrap={(wrappedChildren) => ( + + {wrappedChildren} + + )} + > + 0 ? 'primary' : ''} + titleSize="xs" + isLoading={ruleStatsLoading} + data-test-subj="statErrors" + /> + + ); + return [ + , + disabledStatsComponent, + snoozedStatsComponent, + errorStatsComponent, + , + + {i18n.translate('xpack.observability.alerts.manageRulesButtonLabel', { + defaultMessage: 'Manage Rules', + })} + , + ].reverse(); +}; diff --git a/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/types.ts b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/types.ts new file mode 100644 index 000000000000000..87ff668ebf87f54 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/components/rule_stats/types.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface RuleStatsState { + total: number; + disabled: number; + muted: number; + error: number; + snoozed: number; +} + +export type StatType = 'disabled' | 'snoozed' | 'error'; diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx index e99a3195d0f30da..8838ccd2ac56f5b 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_page/alerts_page.tsx @@ -5,15 +5,13 @@ * 2.0. */ -import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiStat } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { DataViewBase } from '@kbn/es-query'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import useAsync from 'react-use/lib/useAsync'; import { ALERT_STATUS, AlertStatus } from '@kbn/rule-data-utils'; - -import { euiStyled } from '@kbn/kibana-react-plugin/common'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { loadRuleAggregations } from '@kbn/triggers-actions-ui-plugin/public'; import { ParsedTechnicalFields } from '@kbn/rule-registry-plugin/common/parse_technical_fields'; @@ -38,6 +36,7 @@ import { } from '../state_container'; import './styles.scss'; import { AlertsStatusFilter, AlertsDisclaimer, AlertsSearchBar } from '../../components'; +import { renderRuleStats } from '../../components/rule_stats'; import { ObservabilityAppServices } from '../../../../application/types'; import { OBSERVABILITY_RULE_TYPES } from '../../../rules/config'; @@ -57,11 +56,6 @@ export interface TopAlert { active: boolean; } -const Divider = euiStyled.div` - border-right: 1px solid ${({ theme }) => theme.eui.euiColorLightShade}; - height: 100%; -`; - const regExpEscape = (str: string) => str.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); const NO_INDEX_PATTERNS: DataViewBase[] = []; const BASE_ALERT_REGEX = new RegExp(`\\s*${regExpEscape(ALERT_STATUS)}\\s*:\\s*"(.*?|\\*?)"`); @@ -251,54 +245,7 @@ function AlertsPage() { ), - rightSideItems: [ - , - , - , - , - , - - {i18n.translate('xpack.observability.alerts.manageRulesButtonLabel', { - defaultMessage: 'Manage Rules', - })} - , - ].reverse(), + rightSideItems: renderRuleStats(ruleStats, manageRulesHref, ruleStatsLoading), }} > @@ -337,6 +284,7 @@ function AlertsPage() { setRefetch={setRefetch} stateStorageKey={ALERT_TABLE_STATE_STORAGE_KEY} storage={new Storage(window.localStorage)} + itemsPerPage={50} /> diff --git a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx index 686ae9a15d8de86..6ae011e35b0b23a 100644 --- a/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/containers/alerts_table_t_grid/alerts_table_t_grid.tsx @@ -78,6 +78,7 @@ interface AlertsTableTGridProps { stateStorageKey: string; storage: IStorageWrapper; setRefetch: (ref: () => void) => void; + itemsPerPage?: number; } interface ObservabilityActionsProps extends ActionProps { @@ -313,7 +314,16 @@ const FIELDS_WITHOUT_CELL_ACTIONS = [ ]; export function AlertsTableTGrid(props: AlertsTableTGridProps) { - const { indexNames, rangeFrom, rangeTo, kuery, setRefetch, stateStorageKey, storage } = props; + const { + indexNames, + rangeFrom, + rangeTo, + kuery, + setRefetch, + stateStorageKey, + storage, + itemsPerPage, + } = props; const { timelines, @@ -409,6 +419,7 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) { filters: [], hasAlertsCrudPermissions, indexNames, + itemsPerPage, itemsPerPageOptions: [10, 25, 50], loadingText: translations.alertsTable.loadingTextLabel, footerText: translations.alertsTable.footerTextLabel, @@ -459,6 +470,7 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) { deletedEventIds, onStateChange, tGridState, + itemsPerPage, ]); const handleFlyoutClose = () => setFlyoutAlert(undefined); diff --git a/x-pack/plugins/observability/public/pages/overview/index.tsx b/x-pack/plugins/observability/public/pages/overview/index.tsx index 9ceabf7c3111a1c..6d95db9d7694f55 100644 --- a/x-pack/plugins/observability/public/pages/overview/index.tsx +++ b/x-pack/plugins/observability/public/pages/overview/index.tsx @@ -66,6 +66,7 @@ function calculateBucketSize({ start, end }: { start?: number; end?: number }) { } const ALERT_TABLE_STATE_STORAGE_KEY = 'xpack.observability.overview.alert.tableState'; +const ALERTS_PER_PAGE = 10; export function OverviewPage({ routeParams }: Props) { const trackMetric = useUiTracker({ app: 'observability-overview' }); @@ -208,6 +209,7 @@ export function OverviewPage({ routeParams }: Props) { rangeFrom={relativeStart} rangeTo={relativeEnd} indexNames={indexNames} + itemsPerPage={ALERTS_PER_PAGE} stateStorageKey={ALERT_TABLE_STATE_STORAGE_KEY} storage={new Storage(window.localStorage)} /> diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx new file mode 100644 index 000000000000000..e3aadb60f8c4c20 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/components/actions.tsx @@ -0,0 +1,61 @@ +/* + * 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 React from 'react'; +import { + EuiText, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + IconType, + EuiLoadingSpinner, +} from '@elastic/eui'; +import { intersectionBy } from 'lodash'; +import { ActionsProps } from '../types'; +import { useFetchRuleActions } from '../../../hooks/use_fetch_rule_actions'; +import { useKibana } from '../../../utils/kibana_react'; + +interface MapActionTypeIcon { + [key: string]: string | IconType; +} +const mapActionTypeIcon: MapActionTypeIcon = { + /* TODO: Add the rest of the application logs (SVGs ones) */ + '.server-log': 'logsApp', + '.email': 'email', + '.pagerduty': 'apps', + '.index': 'indexOpen', + '.slack': 'logoSlack', + '.webhook': 'logoWebhook', +}; +export function Actions({ ruleActions }: ActionsProps) { + const { + http, + notifications: { toasts }, + } = useKibana().services; + const { isLoadingActions, allActions, errorActions } = useFetchRuleActions({ http }); + if (ruleActions && ruleActions.length <= 0) return 0; + const actions = intersectionBy(allActions, ruleActions, 'actionTypeId'); + if (isLoadingActions) return ; + return ( + + {actions.map((action) => ( + <> + + + + + + {action.name} + + + + + ))} + {errorActions && toasts.addDanger({ title: errorActions })} + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/index.ts b/x-pack/plugins/observability/public/pages/rule_details/components/index.ts new file mode 100644 index 000000000000000..8020af09dedc254 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/components/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { PageTitle } from './page_title'; +export { ItemTitleRuleSummary } from './item_title_rule_summary'; +export { ItemValueRuleSummary } from './item_value_rule_summary'; +export { Actions } from './actions'; diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/item_title_rule_summary.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/item_title_rule_summary.tsx new file mode 100644 index 000000000000000..d2a48059383057c --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/components/item_title_rule_summary.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { EuiFlexItem, EuiTitle } from '@elastic/eui'; +import { ItemTitleRuleSummaryProps } from '../types'; + +export function ItemTitleRuleSummary({ children }: ItemTitleRuleSummaryProps) { + return ( + + + {children} + + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/item_value_rule_summary.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/item_value_rule_summary.tsx new file mode 100644 index 000000000000000..6e178250c53ffc9 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/components/item_value_rule_summary.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { EuiFlexItem, EuiText } from '@elastic/eui'; +import { ItemValueRuleSummaryProps } from '../types'; + +export function ItemValueRuleSummary({ itemValue, extraSpace = true }: ItemValueRuleSummaryProps) { + return ( + + {itemValue} + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rule_details/components/page_title.tsx b/x-pack/plugins/observability/public/pages/rule_details/components/page_title.tsx new file mode 100644 index 000000000000000..478fbf69a226c0e --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/components/page_title.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState } from 'react'; +import moment from 'moment'; +import { EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { ExperimentalBadge } from '../../../components/shared/experimental_badge'; +import { PageHeaderProps } from '../types'; +import { useKibana } from '../../../utils/kibana_react'; +import { LAST_UPDATED_MESSAGE, CREATED_WORD, BY_WORD, ON_WORD } from '../translations'; + +export function PageTitle({ rule }: PageHeaderProps) { + const { triggersActionsUi } = useKibana().services; + const [isTagsPopoverOpen, setIsTagsPopoverOpen] = useState(false); + const tagsClicked = () => + setIsTagsPopoverOpen( + (oldStateIsTagsPopoverOpen) => rule.tags.length > 0 && !oldStateIsTagsPopoverOpen + ); + const closeTagsPopover = () => setIsTagsPopoverOpen(false); + return ( + <> + {rule.name} + + + + {LAST_UPDATED_MESSAGE} {BY_WORD} {rule.updatedBy} {ON_WORD}  + {moment(rule.updatedAt).format('ll')}   + {CREATED_WORD} {BY_WORD} {rule.createdBy} {ON_WORD}  + {moment(rule.createdAt).format('ll')} + + + + {rule.tags.length > 0 && + triggersActionsUi.getRuleTagBadge({ + isOpen: isTagsPopoverOpen, + tags: rule.tags, + onClick: () => tagsClicked(), + onClose: () => closeTagsPopover(), + })} + + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rule_details/config.ts b/x-pack/plugins/observability/public/pages/rule_details/config.ts new file mode 100644 index 000000000000000..e73849f47e7b3ec --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/config.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RuleType, Rule } from '@kbn/triggers-actions-ui-plugin/public'; + +type Capabilities = Record; + +export type InitialRule = Partial & + Pick; + +export function hasAllPrivilege(rule: InitialRule, ruleType?: RuleType): boolean { + return ruleType?.authorizedConsumers[rule.consumer]?.all ?? false; +} + +export const hasExecuteActionsCapability = (capabilities: Capabilities) => + capabilities?.actions?.execute; + +export const RULES_PAGE_LINK = '/app/observability/alerts/rules'; +export const ALERT_PAGE_LINK = '/app/observability/alerts'; diff --git a/x-pack/plugins/observability/public/pages/rule_details/index.tsx b/x-pack/plugins/observability/public/pages/rule_details/index.tsx new file mode 100644 index 000000000000000..ce7049bd610568e --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/index.tsx @@ -0,0 +1,483 @@ +/* + * 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 React, { useState, useEffect, useCallback } from 'react'; +import moment from 'moment'; +import { useParams } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; +import { + EuiText, + EuiSpacer, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiButtonIcon, + EuiPanel, + EuiTitle, + EuiHealth, + EuiPopover, + EuiHorizontalRule, + EuiTabbedContent, + EuiEmptyPrompt, +} from '@elastic/eui'; + +import { + enableRule, + disableRule, + snoozeRule, + unsnoozeRule, + deleteRules, + useLoadRuleTypes, + RuleType, +} from '@kbn/triggers-actions-ui-plugin/public'; +// TODO: use a Delete modal from triggersActionUI when it's sharable +import { ALERTS_FEATURE_ID } from '@kbn/alerting-plugin/common'; +import { DeleteModalConfirmation } from '../rules/components/delete_modal_confirmation'; +import { CenterJustifiedSpinner } from '../rules/components/center_justified_spinner'; +import { getHealthColor, OBSERVABILITY_SOLUTIONS } from '../rules/config'; +import { + RuleDetailsPathParams, + EVENT_ERROR_LOG_TAB, + EVENT_LOG_LIST_TAB, + ALERT_LIST_TAB, +} from './types'; +import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; +import { usePluginContext } from '../../hooks/use_plugin_context'; +import { useFetchRule } from '../../hooks/use_fetch_rule'; +import { RULES_BREADCRUMB_TEXT } from '../rules/translations'; +import { PageTitle, ItemTitleRuleSummary, ItemValueRuleSummary, Actions } from './components'; +import { useKibana } from '../../utils/kibana_react'; +import { useFetchLast24hAlerts } from '../../hooks/use_fetch_last24h_alerts'; +import { formatInterval } from './utils'; +import { + hasExecuteActionsCapability, + hasAllPrivilege, + RULES_PAGE_LINK, + ALERT_PAGE_LINK, +} from './config'; + +export function RuleDetailsPage() { + const { + http, + triggersActionsUi: { ruleTypeRegistry, getRuleStatusDropdown, getEditAlertFlyout }, + application: { capabilities, navigateToUrl }, + notifications: { toasts }, + } = useKibana().services; + + const { ruleId } = useParams(); + const { ObservabilityPageTemplate } = usePluginContext(); + const { isRuleLoading, rule, errorRule, reloadRule } = useFetchRule({ ruleId, http }); + const { ruleTypes } = useLoadRuleTypes({ + filteredSolutions: OBSERVABILITY_SOLUTIONS, + }); + + const [features, setFeatures] = useState(''); + const [ruleType, setRuleType] = useState>(); + const [ruleToDelete, setRuleToDelete] = useState([]); + const [isPageLoading, setIsPageLoading] = useState(false); + const { last24hAlerts } = useFetchLast24hAlerts({ + http, + features, + ruleId, + }); + + const [editFlyoutVisible, setEditFlyoutVisible] = useState(false); + const [isRuleEditPopoverOpen, setIsRuleEditPopoverOpen] = useState(false); + + const handleClosePopover = useCallback(() => setIsRuleEditPopoverOpen(false), []); + + const handleOpenPopover = useCallback(() => setIsRuleEditPopoverOpen(true), []); + + const handleRemoveRule = useCallback(() => { + setIsRuleEditPopoverOpen(false); + if (rule) setRuleToDelete([rule.id]); + }, [rule]); + + const handleEditRule = useCallback(() => { + setIsRuleEditPopoverOpen(false); + setEditFlyoutVisible(true); + }, []); + + useEffect(() => { + if (ruleTypes.length && rule) { + const matchedRuleType = ruleTypes.find((type) => type.id === rule.ruleTypeId); + if (rule.consumer === ALERTS_FEATURE_ID && matchedRuleType && matchedRuleType.producer) { + setRuleType(matchedRuleType); + setFeatures(matchedRuleType.producer); + } else setFeatures(rule.consumer); + } + }, [rule, ruleTypes]); + + useBreadcrumbs([ + { + text: i18n.translate('xpack.observability.breadcrumbs.alertsLinkText', { + defaultMessage: 'Alerts', + }), + href: http.basePath.prepend(ALERT_PAGE_LINK), + }, + { + href: http.basePath.prepend(RULES_PAGE_LINK), + text: RULES_BREADCRUMB_TEXT, + }, + { + text: rule && rule.name, + }, + ]); + + const canExecuteActions = hasExecuteActionsCapability(capabilities); + const canSaveRule = + rule && + hasAllPrivilege(rule, ruleType) && + // if the rule has actions, can the user save the rule's action params + (canExecuteActions || (!canExecuteActions && rule.actions.length === 0)); + + const hasEditButton = + // can the user save the rule + canSaveRule && + // is this rule type editable from within Rules Management + (ruleTypeRegistry.has(rule.ruleTypeId) + ? !ruleTypeRegistry.get(rule.ruleTypeId).requiresAppContext + : false); + + const getRuleConditionsWording = () => { + const numberOfConditions = rule?.params.criteria ? (rule?.params.criteria as any[]).length : 0; + return ( + <> + {numberOfConditions}  + {i18n.translate('xpack.observability.ruleDetails.conditions', { + defaultMessage: 'condition{s}', + values: { s: numberOfConditions > 1 ? 's' : '' }, + })} + + ); + }; + + const tabs = [ + { + id: EVENT_LOG_LIST_TAB, + name: i18n.translate('xpack.observability.ruleDetails.rule.eventLogTabText', { + defaultMessage: 'Execution history', + }), + 'data-test-subj': 'eventLogListTab', + content: Execution history, + }, + { + id: ALERT_LIST_TAB, + name: i18n.translate('xpack.observability.ruleDetails.rule.alertsTabText', { + defaultMessage: 'Alerts', + }), + 'data-test-subj': 'ruleAlertListTab', + content: Alerts, + }, + { + id: EVENT_ERROR_LOG_TAB, + name: i18n.translate('xpack.observability.ruleDetails.rule.errorLogTabText', { + defaultMessage: 'Error log', + }), + 'data-test-subj': 'errorLogTab', + content: Error log, + }, + ]; + + if (isPageLoading || isRuleLoading) return ; + if (!rule || errorRule) + return ( + + + {i18n.translate('xpack.observability.ruleDetails.errorPromptTitle', { + defaultMessage: 'Unable to load rule details', + })} + + } + body={ +

    + {i18n.translate('xpack.observability.ruleDetails.errorPromptBody', { + defaultMessage: 'There was an error loading the rule details.', + })} +

    + } + /> +
    + ); + return ( + , + bottomBorder: false, + rightSideItems: hasEditButton + ? [ + + + + } + > + + + + + {i18n.translate('xpack.observability.ruleDetails.editRule', { + defaultMessage: 'Edit rule', + })} + + + + + + {i18n.translate('xpack.observability.ruleDetails.deleteRule', { + defaultMessage: 'Delete rule', + })} + + + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.triggreAction.status', { + defaultMessage: 'Status', + })} + + + + {getRuleStatusDropdown({ + rule, + enableRule: async () => await enableRule({ http, id: rule.id }), + disableRule: async () => await disableRule({ http, id: rule.id }), + onRuleChanged: () => reloadRule(), + isEditable: hasEditButton, + snoozeRule: async (snoozeEndTime: string | -1) => { + await snoozeRule({ http, id: rule.id, snoozeEndTime }); + }, + unsnoozeRule: async () => await unsnoozeRule({ http, id: rule.id }), + })} + + , + ] + : [], + }} + > + + {/* Left side of Rule Summary */} + + + + + + + {rule.executionStatus.status.charAt(0).toUpperCase() + + rule.executionStatus.status.slice(1)} + + + + + + + {i18n.translate('xpack.observability.ruleDetails.lastRun', { + defaultMessage: 'Last Run', + })} + + + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.alerts', { + defaultMessage: 'Alerts', + })} + + + + + + + + + + + {/* Right side of Rule Summary */} + + + + + + + {i18n.translate('xpack.observability.ruleDetails.definition', { + defaultMessage: 'Definition', + })} + + + {hasEditButton && ( + + setEditFlyoutVisible(true)} /> + + )} + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.ruleType', { + defaultMessage: 'Rule type', + })} + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.description', { + defaultMessage: 'Description', + })} + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.conditionsTitle', { + defaultMessage: 'Conditions', + })} + + + + {hasEditButton ? ( + setEditFlyoutVisible(true)}> + {getRuleConditionsWording()} + + ) : ( + {getRuleConditionsWording()} + )} + + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.runsEvery', { + defaultMessage: 'Runs every', + })} + + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.notifyWhen', { + defaultMessage: 'Notify', + })} + + + + + + + + + {i18n.translate('xpack.observability.ruleDetails.actions', { + defaultMessage: 'Actions', + })} + + + + + + + + + + + + + + {editFlyoutVisible && + getEditAlertFlyout({ + initialRule: rule, + onClose: () => { + setEditFlyoutVisible(false); + }, + onSave: reloadRule, + })} + { + setRuleToDelete([]); + navigateToUrl(http.basePath.prepend(RULES_PAGE_LINK)); + }} + onErrors={async () => { + setRuleToDelete([]); + navigateToUrl(http.basePath.prepend(RULES_PAGE_LINK)); + }} + onCancel={() => {}} + apiDeleteCall={deleteRules} + idsToDelete={ruleToDelete} + singleTitle={rule.name} + multipleTitle={rule.name} + setIsLoadingState={(isLoading: boolean) => { + setIsPageLoading(isLoading); + }} + /> + {errorRule && toasts.addDanger({ title: errorRule })} + + ); +} diff --git a/x-pack/plugins/observability/public/pages/rule_details/translations.ts b/x-pack/plugins/observability/public/pages/rule_details/translations.ts new file mode 100644 index 000000000000000..f162f30906c2168 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/translations.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; + +export const RULE_LOAD_ERROR = (errorMessage: string) => + i18n.translate('xpack.observability.ruleDetails.ruleLoadError', { + defaultMessage: 'Unable to load rule. Reason: {message}', + values: { message: errorMessage }, + }); + +export const ACTIONS_LOAD_ERROR = (errorMessage: string) => + i18n.translate('xpack.observability.ruleDetails.connectorsLoadError', { + defaultMessage: 'Unable to load rule actions connectors. Reason: {message}', + values: { message: errorMessage }, + }); + +export const TAGS_TITLE = i18n.translate('xpack.observability.ruleDetails.tagsTitle', { + defaultMessage: 'Tags', +}); + +export const LAST_UPDATED_MESSAGE = i18n.translate( + 'xpack.observability.ruleDetails.lastUpdatedMessage', + { + defaultMessage: 'Last updated', + } +); + +export const BY_WORD = i18n.translate('xpack.observability.ruleDetails.byWord', { + defaultMessage: 'by', +}); + +export const ON_WORD = i18n.translate('xpack.observability.ruleDetails.onWord', { + defaultMessage: 'on', +}); + +export const CREATED_WORD = i18n.translate('xpack.observability.ruleDetails.createdWord', { + defaultMessage: 'Created', +}); diff --git a/x-pack/plugins/observability/public/pages/rule_details/types.ts b/x-pack/plugins/observability/public/pages/rule_details/types.ts new file mode 100644 index 000000000000000..9855bf2c7f184fc --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/types.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HttpSetup } from '@kbn/core/public'; +import { Rule, RuleSummary, RuleType } from '@kbn/triggers-actions-ui-plugin/public'; + +export interface RuleDetailsPathParams { + ruleId: string; +} +export interface PageHeaderProps { + rule: Rule; +} + +export interface FetchRuleProps { + ruleId: string; + http: HttpSetup; +} + +export interface FetchRule { + isRuleLoading: boolean; + rule?: Rule; + ruleType?: RuleType; + errorRule?: string; +} + +export interface FetchRuleSummaryProps { + ruleId: string; + http: HttpSetup; +} +export interface FetchRuleActionsProps { + http: HttpSetup; +} + +export interface FetchRuleSummary { + isLoadingRuleSummary: boolean; + ruleSummary?: RuleSummary; + errorRuleSummary?: string; +} + +export interface AlertListItemStatus { + label: string; + healthColor: string; + actionGroup?: string; +} +export interface AlertListItem { + alert: string; + status: AlertListItemStatus; + start?: Date; + duration: number; + isMuted: boolean; + sortPriority: number; +} +export interface ItemTitleRuleSummaryProps { + children: string; +} +export interface ItemValueRuleSummaryProps { + itemValue: string; + extraSpace?: boolean; +} +export interface ActionsProps { + ruleActions: any[]; +} + +export const EVENT_LOG_LIST_TAB = 'rule_event_log_list'; +export const ALERT_LIST_TAB = 'rule_alert_list'; +export const EVENT_ERROR_LOG_TAB = 'rule_error_log_list'; diff --git a/x-pack/plugins/observability/public/pages/rule_details/utils.ts b/x-pack/plugins/observability/public/pages/rule_details/utils.ts new file mode 100644 index 000000000000000..0c907d93228a6cc --- /dev/null +++ b/x-pack/plugins/observability/public/pages/rule_details/utils.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { formatDurationFromTimeUnitChar, TimeUnitChar } from '../../../common'; + +export const formatInterval = (ruleInterval: string) => { + const interval: string[] | null = ruleInterval.match(/(^\d*)([s|m|h|d])/); + if (!interval || interval.length < 3) return ruleInterval; + const value: number = +interval[1]; + const unit = interval[2] as TimeUnitChar; + return formatDurationFromTimeUnitChar(value, unit); +}; diff --git a/x-pack/plugins/observability/public/pages/rules/components/name.tsx b/x-pack/plugins/observability/public/pages/rules/components/name.tsx index cbde68ea27eb4a8..15cb44412d88005 100644 --- a/x-pack/plugins/observability/public/pages/rules/components/name.tsx +++ b/x-pack/plugins/observability/public/pages/rules/components/name.tsx @@ -12,9 +12,7 @@ import { useKibana } from '../../../utils/kibana_react'; export function Name({ name, rule }: RuleNameProps) { const { http } = useKibana().services; - const detailsLink = http.basePath.prepend( - `/app/management/insightsAndAlerting/triggersActions/rule/${rule.id}` - ); + const detailsLink = http.basePath.prepend(`/app/observability/alerts/rules/${rule.id}`); const link = ( diff --git a/x-pack/plugins/observability/public/pages/rules/index.test.tsx b/x-pack/plugins/observability/public/pages/rules/index.test.tsx index ac31587d747029c..6987026b3b9bd39 100644 --- a/x-pack/plugins/observability/public/pages/rules/index.test.tsx +++ b/x-pack/plugins/observability/public/pages/rules/index.test.tsx @@ -86,7 +86,11 @@ describe('empty RulesPage', () => { }, ], }); - useFetchRules.mockReturnValue({ rulesState, noData: true }); + useFetchRules.mockReturnValue({ + rulesState, + noData: true, + tagsState: { data: [], error: null }, + }); wrapper = mountWithIntl(); } it('renders empty screen', async () => { @@ -138,7 +142,11 @@ describe('empty RulesPage with show only capability', () => { ruleTaskTimeout: '1m', }, ]; - useFetchRules.mockReturnValue({ rulesState, noData: true }); + useFetchRules.mockReturnValue({ + rulesState, + noData: true, + tagsState: { data: [], error: null }, + }); useLoadRuleTypes.mockReturnValue({ ruleTypes }); wrapper = mountWithIntl(); @@ -352,7 +360,7 @@ describe('RulesPage with items', () => { ruleTypes, ruleTypeIndex: mockedRuleTypeIndex, }); - useFetchRules.mockReturnValue({ rulesState }); + useFetchRules.mockReturnValue({ rulesState, tagsState: { data: [], error: null } }); wrapper = mountWithIntl(); await act(async () => { await nextTick(); @@ -509,7 +517,7 @@ describe('RulesPage with items and show only capability', () => { error: null, totalItemCount: 3, }; - useFetchRules.mockReturnValue({ rulesState }); + useFetchRules.mockReturnValue({ rulesState, tagsState: { data: [], error: null } }); const mockedRuleTypeIndex = new Map( Object.entries({ diff --git a/x-pack/plugins/observability/public/pages/rules/index.tsx b/x-pack/plugins/observability/public/pages/rules/index.tsx index a409754a51a1412..4ab0790cf5bd46e 100644 --- a/x-pack/plugins/observability/public/pages/rules/index.tsx +++ b/x-pack/plugins/observability/public/pages/rules/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState, useMemo, useCallback } from 'react'; +import React, { useState, useMemo, useCallback, useEffect } from 'react'; import { capitalize, sortBy } from 'lodash'; import { EuiButton, @@ -93,6 +93,7 @@ function RulesPage() { }); const [inputText, setInputText] = useState(); const [searchText, setSearchText] = useState(); + const [tagsFilter, setTagsFilter] = useState([]); const [typesFilter, setTypesFilter] = useState([]); const { lastResponse, setLastResponse } = useRulesPageStateContainer(); const { status, setStatus } = useRulesPageStateContainer(); @@ -108,16 +109,19 @@ function RulesPage() { setCurrentRuleToEdit(ruleItem); }; - const { rulesState, setRulesState, reload, noData, initialLoad } = useFetchRules({ + const { rulesState, setRulesState, reload, noData, initialLoad, tagsState } = useFetchRules({ searchText, ruleLastResponseFilter: lastResponse, ruleStatusesFilter: status, typesFilter, + tagsFilter, page, setPage, sort, }); const { data: rules, totalItemCount, error } = rulesState; + const { data: tags, error: tagsError } = tagsState; + const { ruleTypeIndex, ruleTypes } = useLoadRuleTypes({ filteredSolutions: OBSERVABILITY_SOLUTIONS, }); @@ -165,6 +169,18 @@ function RulesPage() { }, ]); + useEffect(() => { + if (tagsError) { + toasts.addDanger({ + title: tagsError, + }); + } + if (error) + toasts.addDanger({ + title: error, + }); + }, [tagsError, error, toasts]); + const getRulesTableColumns = () => { return [ { @@ -182,11 +198,11 @@ function RulesPage() { sortable: false, width: '50px', 'data-test-subj': 'rulesTableCell-tagsPopover', - render: (tags: string[], item: RuleTableItem) => { - return tags.length > 0 + render: (ruleTags: string[], item: RuleTableItem) => { + return ruleTags.length > 0 ? triggersActionsUi.getRuleTagBadge({ isOpen: tagPopoverOpenIndex === item.index, - tags, + tags: ruleTags, onClick: () => setTagPopoverOpenIndex(item.index), onClose: () => setTagPopoverOpenIndex(-1), }) @@ -352,6 +368,13 @@ function RulesPage() { )} /> + + {triggersActionsUi.getRuleTagFilter({ + tags, + selectedTags: tagsFilter, + onChange: (myTags: string[]) => setTagsFilter(myTags), + })} + ); }; - return ( {getRulesTable()} - {error && - toasts.addDanger({ - title: error, - })} {currentRuleToEdit && } {createRuleFlyoutVisibility && CreateRuleFlyout} diff --git a/x-pack/plugins/observability/public/pages/rules/translations.ts b/x-pack/plugins/observability/public/pages/rules/translations.ts index 69f0b5beebf46e5..8484637e25e6076 100644 --- a/x-pack/plugins/observability/public/pages/rules/translations.ts +++ b/x-pack/plugins/observability/public/pages/rules/translations.ts @@ -125,6 +125,13 @@ export const RULES_LOAD_ERROR = i18n.translate('xpack.observability.rules.loadEr defaultMessage: 'Unable to load rules', }); +export const RULE_TAGS_LOAD_ERROR = i18n.translate( + 'xpack.observability.rulesList.unableToLoadRuleTags', + { + defaultMessage: 'Unable to load rule tags', + } +); + export const RULES_SINGLE_TITLE = i18n.translate( 'xpack.observability.rules.rulesTable.singleTitle', { diff --git a/x-pack/plugins/observability/public/pages/rules/types.ts b/x-pack/plugins/observability/public/pages/rules/types.ts index 866e5de91d9c582..f7abdc6fd274e37 100644 --- a/x-pack/plugins/observability/public/pages/rules/types.ts +++ b/x-pack/plugins/observability/public/pages/rules/types.ts @@ -44,6 +44,7 @@ export interface FetchRulesProps { ruleLastResponseFilter: string[]; ruleStatusesFilter: RuleStatus[]; typesFilter: string[]; + tagsFilter: string[]; page: Pagination; setPage: Dispatch>; sort: EuiTableSortingType['sort']; @@ -66,3 +67,8 @@ export interface RuleState { error: string | null; totalItemCount: number; } + +export interface TagsState { + data: string[]; + error: string | null; +} diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index cb8dcaf2dd7e4ec..434bce3c576bfa3 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -33,6 +33,7 @@ import { } from '@kbn/triggers-actions-ui-plugin/public'; import { KibanaFeature } from '@kbn/features-plugin/common'; +import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; import { ConfigSchema } from '.'; import { observabilityAppId, observabilityFeatureId, casesPath } from '../common'; import { createLazyObservabilityPageTemplate } from './components/shared'; @@ -52,9 +53,11 @@ export interface ObservabilityPublicPluginsSetup { data: DataPublicPluginSetup; triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; home?: HomePublicPluginSetup; + usageCollection: UsageCollectionSetup; } export interface ObservabilityPublicPluginsStart { + usageCollection: UsageCollectionSetup; cases: CasesUiStart; embeddable: EmbeddableStart; home?: HomePublicPluginStart; @@ -169,6 +172,7 @@ export class Plugin observabilityRuleTypeRegistry, ObservabilityPageTemplate: navigation.PageTemplate, kibanaFeatures, + usageCollection: pluginsSetup.usageCollection, }); }; diff --git a/x-pack/plugins/observability/public/routes/index.tsx b/x-pack/plugins/observability/public/routes/index.tsx index 528dbfee06f9d2a..867e44613e07c16 100644 --- a/x-pack/plugins/observability/public/routes/index.tsx +++ b/x-pack/plugins/observability/public/routes/index.tsx @@ -7,6 +7,7 @@ import * as t from 'io-ts'; import React from 'react'; +import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; import { casesPath } from '../../common'; import { CasesPage } from '../pages/cases'; import { AlertsPage } from '../pages/alerts/containers/alerts_page'; @@ -16,6 +17,8 @@ import { OverviewPage } from '../pages/overview'; import { jsonRt } from './json_rt'; import { ObservabilityExploratoryView } from '../components/shared/exploratory_view/obsv_exploratory_view'; import { RulesPage } from '../pages/rules'; +import { RuleDetailsPage } from '../pages/rule_details'; +import { AlertingPages } from '../config'; export type RouteParams = DecodeParams; @@ -60,14 +63,22 @@ export const routes = { }, [casesPath]: { handler: () => { - return ; + return ( + + + + ); }, params: {}, exact: false, }, '/alerts': { handler: () => { - return ; + return ( + + + + ); }, params: { // Technically gets a '_a' param by using Kibana URL state sync helpers @@ -90,7 +101,18 @@ export const routes = { }, '/alerts/rules': { handler: () => { - return ; + return ( + + + + ); + }, + params: {}, + exact: true, + }, + '/alerts/rules/:ruleId': { + handler: () => { + return ; }, params: {}, exact: true, diff --git a/x-pack/plugins/observability/server/ui_settings.ts b/x-pack/plugins/observability/server/ui_settings.ts index 02c519b10d19cec..5b21b07d1cea3e5 100644 --- a/x-pack/plugins/observability/server/ui_settings.ts +++ b/x-pack/plugins/observability/server/ui_settings.ts @@ -18,6 +18,7 @@ import { apmProgressiveLoading, enableServiceGroups, apmServiceInventoryOptimizedSorting, + enableNewSyntheticsView, } from '../common/ui_settings_keys'; const technicalPreviewLabel = i18n.translate( @@ -31,6 +32,22 @@ const technicalPreviewLabel = i18n.translate( * uiSettings definitions for Observability. */ export const uiSettings: Record> = { + [enableNewSyntheticsView]: { + category: [observabilityFeatureId], + name: i18n.translate('xpack.observability.enableNewSyntheticsViewExperimentName', { + defaultMessage: 'Enable new synthetic monitoring application', + }), + value: false, + description: i18n.translate( + 'xpack.observability.enableNewSyntheticsViewExperimentDescription', + { + defaultMessage: + 'Enable new synthetic monitoring application in observability. Refresh the page to apply the setting.', + } + ), + schema: schema.boolean(), + requiresPageReload: true, + }, [enableInspectEsQueries]: { category: [observabilityFeatureId], name: i18n.translate('xpack.observability.enableInspectEsQueriesExperimentName', { @@ -71,7 +88,7 @@ export const uiSettings: Record { +describe('ALL - Edit saved query', () => { const SAVED_QUERY_ID = 'Saved-Query-Id'; before(() => { @@ -25,7 +25,7 @@ describe('ALL - Delete ECS Mappings', () => { runKbnArchiverScript(ArchiverMethod.UNLOAD, 'saved_query'); }); - it('to click the edit button and edit pack', () => { + it('by changing ecs mappings and platforms', () => { cy.react('CustomItemAction', { props: { index: 1, item: { attributes: { id: SAVED_QUERY_ID } } }, }).click(); @@ -35,7 +35,33 @@ describe('ALL - Delete ECS Mappings', () => { .parents('[data-test-subj="ECSMappingEditorForm"]') .react('EuiButtonIcon', { props: { iconType: 'trash' } }) .click(); + + cy.react('PlatformCheckBoxGroupField').within(() => { + cy.react('EuiCheckbox', { + props: { + id: 'linux', + checked: true, + }, + }).should('exist'); + cy.react('EuiCheckbox', { + props: { + id: 'darwin', + checked: true, + }, + }).should('exist'); + + cy.react('EuiCheckbox', { + props: { + id: 'windows', + checked: false, + }, + }).should('exist'); + }); + + cy.get('#windows').check({ force: true }); + cy.react('EuiButton').contains('Update query').click(); + cy.wait(5000); cy.react('CustomItemAction', { @@ -43,5 +69,27 @@ describe('ALL - Delete ECS Mappings', () => { }).click(); cy.contains('Custom key/value pairs').should('not.exist'); cy.contains('Hours of uptime').should('not.exist'); + + cy.react('PlatformCheckBoxGroupField').within(() => { + cy.react('EuiCheckbox', { + props: { + id: 'linux', + checked: true, + }, + }).should('exist'); + cy.react('EuiCheckbox', { + props: { + id: 'darwin', + checked: true, + }, + }).should('exist'); + + cy.react('EuiCheckbox', { + props: { + id: 'windows', + checked: true, + }, + }).should('exist'); + }); }); }); diff --git a/x-pack/plugins/osquery/public/saved_queries/form/use_saved_query_form.tsx b/x-pack/plugins/osquery/public/saved_queries/form/use_saved_query_form.tsx index 6da252f78aedf88..1d0d9f28d097b63 100644 --- a/x-pack/plugins/osquery/public/saved_queries/form/use_saved_query_form.tsx +++ b/x-pack/plugins/osquery/public/saved_queries/form/use_saved_query_form.tsx @@ -56,13 +56,6 @@ export const useSavedQueryForm = ({ defaultValue, handleSubmit }: UseSavedQueryF defaultValue, serializer: (payload) => produce(payload, (draft) => { - // @ts-expect-error update types - if (draft.platform?.split(',').length === 3) { - // if all platforms are checked then use undefined - // @ts-expect-error update types - delete draft.platform; - } - if (isArray(draft.version)) { if (!draft.version.length) { // @ts-expect-error update types diff --git a/x-pack/plugins/osquery/server/lib/telemetry/sender.ts b/x-pack/plugins/osquery/server/lib/telemetry/sender.ts index ab5af7d60f466fe..a2acc2fe8ec6f82 100644 --- a/x-pack/plugins/osquery/server/lib/telemetry/sender.ts +++ b/x-pack/plugins/osquery/server/lib/telemetry/sender.ts @@ -267,8 +267,8 @@ export class TelemetryEventsSender { const resp = await axios.post(telemetryUrl, ndjson, { headers: { 'Content-Type': 'application/x-ndjson', - 'X-Elastic-Cluster-ID': clusterUuid, - 'X-Elastic-Cluster-Name': clusterName, + ...(clusterUuid ? { 'X-Elastic-Cluster-ID': clusterUuid } : undefined), + ...(clusterName ? { 'X-Elastic-Cluster-Name': clusterName } : undefined), 'X-Elastic-Stack-Version': clusterVersionNumber ? clusterVersionNumber : '8.0.0', ...(licenseId ? { 'X-Elastic-License-ID': licenseId } : {}), }, diff --git a/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap b/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap index 4187db5b20641c9..935f3e297b2cb93 100644 --- a/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap +++ b/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap @@ -188,42 +188,40 @@ Array [ `; exports[`stream handler showNotifications show success 1`] = ` -Array [ - Object { - "color": "success", - "data-test-subj": "completeReportSuccess", - "text": MountPoint { - "reactNode": -

    - -

    - +

    + - , - }, - "title": MountPoint { - "reactNode": + , - }, + /> + , }, -] + "title": MountPoint { + "reactNode": , + }, +} `; diff --git a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts index 6f575652450c1a9..d3075d4e5a90698 100644 --- a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts +++ b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { omit } from 'lodash'; import sinon, { stub } from 'sinon'; import { NotificationsStart } from '@kbn/core/public'; import { coreMock, themeServiceMock, docLinksServiceMock } from '@kbn/core/public/mocks'; @@ -123,7 +124,7 @@ describe('stream handler', () => { expect(mockShowDanger.callCount).toBe(0); expect(mockShowSuccess.callCount).toBe(1); expect(mockShowWarning.callCount).toBe(0); - expect(mockShowSuccess.args[0]).toMatchSnapshot(); + expect(omit(mockShowSuccess.args[0][0], 'toastLifeTimeMs')).toMatchSnapshot(); done(); }); }); diff --git a/x-pack/plugins/reporting/public/notifier/job_success.tsx b/x-pack/plugins/reporting/public/notifier/job_success.tsx index 44389e164472ac8..f7b71d78de8bd29 100644 --- a/x-pack/plugins/reporting/public/notifier/job_success.tsx +++ b/x-pack/plugins/reporting/public/notifier/job_success.tsx @@ -37,5 +37,12 @@ export const getSuccessToast = ( , { theme$: theme.theme$ } ), + /** + * If timeout is an Infinity value, a Not-a-Number (NaN) value, or negative, then timeout will be zero. + * And we cannot use `Number.MAX_SAFE_INTEGER` because EUI's Timer implementation + * subtracts it from the current time to evaluate the remainder. + * @see https://www.w3.org/TR/2011/WD-html5-20110525/timers.html + */ + toastLifeTimeMs: Number.MAX_SAFE_INTEGER - Date.now(), 'data-test-subj': 'completeReportSuccess', }); diff --git a/x-pack/plugins/reporting/public/share_context_menu/__snapshots__/screen_capture_panel_content.test.tsx.snap b/x-pack/plugins/reporting/public/share_context_menu/__snapshots__/screen_capture_panel_content.test.tsx.snap index 34825e05117077e..1f9c47145d71513 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/__snapshots__/screen_capture_panel_content.test.tsx.snap +++ b/x-pack/plugins/reporting/public/share_context_menu/__snapshots__/screen_capture_panel_content.test.tsx.snap @@ -1,1256 +1,554 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ScreenCapturePanelContent properly renders a view with "canvas" layout option 1`] = ` -

    - -
    -

    - - - Analytical Apps can take a minute or two to generate based upon the size of your test-object-type. - - -

    -
    -
    - -
    - - - } - labelType="label" +

    + + Analytical Apps can take a minute or two to generate based upon the size of your test-object-type. + +

    +
    +
    +
    +
    -
    - - } - onBlur={[Function]} - onChange={[Function]} - onFocus={[Function]} - > -
    - - - - - Full page layout - - - -
    -
    - -
    - - - Remove borders and footer logo - - -
    -
    -
    -
    - - - - - - -
    -
    - + + + Full page layout + + +
    -
    - - - - -
    -
    - -
    -
    - -
    - - -
    -

    - - - Alternatively, copy this POST URL to call generation from outside Kibana or from Watcher. - - -

    -
    -
    - -
    - - - -
    -
    - - - Unsaved work - -
    - -
    - -
    - -
    -

    - - - Save your work before copying this URL. - - -

    -
    -
    - -
    - -
    - -
    - -
    - - -
    -
    - -
    + + Remove borders and footer logo +
    - +
    - -`; - -exports[`ScreenCapturePanelContent properly renders a view with "print" layout option 1`] = ` - -
    - -
    -

    - - - Analytical Apps can take a minute or two to generate based upon the size of your test-object-type. - - -

    -
    -
    - + Generate Analytical App + + + + +
    +
    +
    -
    - -